diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bf51e5..01e0b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,8 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run tests - run: swift test + run: swift test -c release + - name: Check release validation and process cleanup + run: | + Scripts/validate-release.sh --self-test + python3 Scripts/check-grok-exit.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f0820c..5240447 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,9 +83,14 @@ jobs: run: | set -euo pipefail app='.build/release/Codex Limits.app' + helper="$app/Contents/Helpers/CodexLimitsClaudeRelay" codesign --verify --deep --strict "$app" + test -x "$helper" + codesign --verify --strict "$helper" archs=$(lipo -archs "$app/Contents/MacOS/CodexLimits") test "$archs" = 'x86_64 arm64' -o "$archs" = 'arm64 x86_64' + helper_archs=$(lipo -archs "$helper") + test "$helper_archs" = 'x86_64 arm64' -o "$helper_archs" = 'arm64 x86_64' xmllint --noout "$ARTIFACTS/appcast.xml" grep -q 'sparkle:edSignature' "$ARTIFACTS/appcast.xml" grep -q 'sparkle:criticalUpdate' "$ARTIFACTS/appcast.xml" diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..5556863 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,309 @@ +# Codex Usage Analytics + +This context describes how Codex Limits represents account allowance and local activity from supported coding-agent integrations, while preserving the deeper Codex guidance and analytics that require Codex-specific evidence. + +## Language + +**Integration**: +A user-managed coding-agent product that Codex Limits observes through an official local interface. An Integration may expose account allowance, local activity, or both. +_Avoid_: Vendor, provider when referring to the whole integrated product + +**Enabled Integration**: +An Integration the user has chosen to include in Codex Limits. Only Enabled Integrations may perform source work or appear in the Integration Overview; enabling one does not imply that every Integration Capability is supported or currently available. +_Avoid_: Installed integration, detected integration, active provider + +**Integration Snapshot**: +The latest normalized observation for one Integration and capability, carrying its source, source version, observed time, and Freshness. A snapshot never combines unlike facts from multiple Integrations. +_Avoid_: Combined provider state, live data when only last observed + +**Integration Capability**: +A supported class of information exposed by an Integration: Account Allowance, Account Facts, Local Activity, Guidance, or Analysis. Capabilities are independent; supporting one never implies support for the others. +_Avoid_: Provider parity, reduced Codex feature set + +**Unsupported Capability**: +An Integration Capability that the Integration does not expose under the current product contract. It is omitted from the reader experience rather than displayed as zero or temporarily unavailable. +_Avoid_: Missing data, zero usage, unavailable source + +**Integration Overview**: +The at-a-glance surface that presents the strongest supported facts for every enabled Integration without combining unlike allowances or forcing identical cards. +_Avoid_: Combined allowance, provider leaderboard + +**Menu Bar Metric**: +The single fixed, named metric selected from an Enabled Integration for display beside the menu bar icon, or None. The selection names both the Integration and quantity; Codex Limits never combines metrics from multiple Integrations. +_Avoid_: Primary provider, combined menu metric, most constrained integration + +**Integration Readiness**: +The device-local ability of an Enabled Integration to produce its supported facts. Readiness describes setup and source compatibility independently from whether the Integration is enabled. +_Avoid_: Enabled when meaning ready, installed when meaning detected + +**Freshness**: +The age and current availability of an Integration Snapshot relative to its source-specific policy. A failed refresh preserves the last valid snapshot and marks it stale; Freshness does not express accuracy. +_Avoid_: Confidence, live when the source is event-driven + +**Expired Allowance Snapshot**: +An otherwise valid Account Allowance observation whose known reset boundary has passed without a post-reset observation. Its former percentage is not displayed as the current allowance. +_Avoid_: Stale percentage after reset, zero remaining + +**Bounded Working Set**: +The fixed upper bound on history, source payload, decoded facts, and reader state kept in resident memory for the current operation or visible range. On-disk retention may grow without making the working set grow proportionally. +_Avoid_: Loading all retained history, retention limit when meaning memory limit + +**Local Installation Partition**: +An on-device history boundary used when an Integration does not expose a stable supported account identity. It is never synchronized or joined with another Mac in v1. +_Avoid_: Anonymous account, shared unknown account + +**Analytics Workspace**: +The unified, screen-aware product surface containing current guidance and switchable Graphs, Facts, and Insights views. +_Avoid_: Status widget, separate analytics app + +**Local-only**: +A product boundary in which Codex-derived data, analysis, and derived history remain on the user’s device and are not transmitted as product telemetry. +_Avoid_: Private, anonymous cloud analytics + +**Source Content**: +Prompts, responses, code, paths, commands, and tool output contained in existing Codex records. +_Avoid_: Metadata, usage data + +**Derived Record**: +A compact fact, classification, aggregate, or fingerprint produced from Source Content without duplicating that content. +_Avoid_: Raw log copy, transcript + +**Analytics History**: +Derived Records kept without a time limit until the user deletes them. `Delete analytics history` removes the entire store owned by Codex Limits, including account usage samples in the selected sync folder. Rebuild requires a separate user action and can restore only facts whose Codex sources remain available. +_Avoid_: Cloud history, raw archive + +**Machine-local Time**: +Reader-facing dates and times shown using the Mac's current calendar and time-zone settings, including daylight-saving changes. +_Avoid_: Fixed CET, reader-facing UTC + +**Rolling 24-hour Interval**: +The exact 86,400 seconds ending at the current instant on the Mac, shown in Machine-local Time. A daylight-saving transition may make its displayed clock times differ by one hour without changing its duration; stale observations do not move the interval into the past. +_Avoid_: Today, yesterday, calendar day + +**Rolling Preset Range**: +A preset duration such as 3 days, 4 weeks, or 12 weeks ending at the current instant on the Mac. Available observations fill the range without moving its end to the latest observation. +_Avoid_: Preset ending at data freshness + +**Selected-range Token Activity**: +Token Activity from complete Token Activity Intervals contained inside the selected interval. It may cover only part of the selected interval and never implies that missing or boundary-crossing time contained zero activity. +_Avoid_: Full-range total when observations cover only part of the range + +**Observed Interval**: +The actual period between the readings that support a displayed fact. When it is shorter than the selected range, the product shows its start and end instead of replacing the fact with a coverage grade. +_Avoid_: Coverage label as a substitute for the actual period + +**Token Activity Interval**: +The Token Activity measured between two account readings. A zero-token interval is an observation, while missing or future time remains empty; the product does not invent when non-zero activity occurred inside an interval. +_Avoid_: Hourly activity inferred by evenly spreading an interval total + +**Allowance**: +The remaining account capacity reported for a Codex rate-limit window. +_Avoid_: Tokens, credits, balance + +**Usage Remaining**: +The reader-facing percentage of Allowance that remains in a window. Codex Limits uses this orientation for every primary usage display. +_Avoid_: Usage left, allowance remaining, usage used + +**Allowance Window**: +A bounded period with a reported allowance and a scheduled reset time. +_Avoid_: Subscription limit, weekly entitlement + +**Current-window Range**: +The full current Allowance Window from its start through its scheduled reset. Observed series leave future time empty; the range does not stop at the latest observation. +_Avoid_: Current window to date, observed range + +**Weekly Allowance Window**: +The Codex Allowance Window whose reported duration is 10,080 minutes. It is the primary window for the menu bar, current guidance, Runway, Suggested Pace, and default Usage remaining chart. +_Avoid_: Most depleted window, combined limit + +**Account Movement**: +An observed change in allowance between two compatible account readings. A long interval limits knowledge of when movement happened inside it, but does not erase the known total movement. +_Avoid_: Charge, billed usage + +**Allowance Break**: +A boundary created by a reset, correction, account change, or incompatible increase in Usage Remaining. No Runway pace calculation crosses it; observation restarts on its latest side. +_Avoid_: Negative allowance use, pace calculated through a reset + +**Token Activity**: +The number of tokens reported for observed Codex activity, either by the account summary or by local task records. It is observed consumption, not a token entitlement, and is never projected into the future. +_Avoid_: Token allowance, token limit + +**Account Token Activity**: +Token Activity reported by the account summary using one strongest available method. Lifetime-token intervals are primary; UTC Account Daily Token Buckets are a fallback and are never mixed with them in one selected-range total. +_Avoid_: Local tokens, weekly token allowance + +**Account Reading Timeline**: +One chronological series of compatible readings for the same account and Allowance Window, merged from every synced installation. Installations provide additional observations of one account counter; their values are never added together. +_Avoid_: Per-Mac account totals, sum of installation counters + +**Account Counter Break**: +A boundary where the lifetime-token counter decreases or readings otherwise become incompatible. No Token Activity Interval crosses the boundary; valid intervals on either side remain factual. +_Avoid_: Negative Token Activity, discarding every valid interval in the selected range + +**Account Daily Token Bucket**: +Token Activity reported by the account API for a UTC calendar-day interval. It contributes to a selected-range total only when its whole interval is inside that range; changing its displayed timezone never turns it into a Machine-local calendar-day total. +_Avoid_: Local daily total, clipped daily bucket + +**Current-window Token Activity**: +Selected-range Token Activity from the start of the current Allowance Window through the latest observation. While the window remains open, it is shown as activity "so far," never as a complete-window total. +_Avoid_: Complete weekly token total before reset + +**Account Facts**: +Values returned directly by the account API, including lifetime tokens, peak daily tokens, longest running turn, streaks, credits, and spend-control state when available. +_Avoid_: Estimates, local diagnostics + +**Local Token Activity**: +Token Activity observed in local task records and attributable to a task, agent, or model. +_Avoid_: Account total, billed tokens + +**Local Coverage**: +An assessment of how much account-visible activity can be represented by local task records for the same period. It is numeric only when the source definitions and time boundaries align. +_Avoid_: Match rate, missing billing + +**Allowance Intensity**: +The observed Account Movement associated with a unit of Token Activity under a particular workload mix. +_Avoid_: Token price, billing rate + +**Equivalent Capacity**: +An extrapolation of how much Token Activity would correspond to a full allowance at the observed workload mix and Allowance Intensity. +_Avoid_: Weekly token allowance, guaranteed capacity + +**Comparable Workload Cost**: +The Allowance Intensity of a workload cohort compared with its Reference Baseline. +_Avoid_: Limit reduction, price increase + +**Comparable Work**: +Two bounded weekly intervals from the same account whose observed model, reasoning, cache, and Local Coverage mix passes the current measurement contract. +_Avoid_: Similar-looking task, same prompt + +**Reference Baseline**: +The median of the four previous complete High-comparability weekly windows unless the user pins another qualifying historical period. +_Avoid_: Average week, official baseline + +**Banked Reset**: +An available account reset that can restore eligible allowance windows when redeemed. +_Avoid_: Emergency reset, bonus credit + +**Read-only Analytics**: +The part of Codex Limits that reads data, calculates metrics, shows guidance, sends reminders, and runs user-requested analysis without changing Codex state. +_Avoid_: Read-only product + +**Reset Reminder**: +A local notification scheduled before the Next Known Expiry. It prompts the user to check the reset and never redeems it. +_Avoid_: Reset Automation, auto-use + +**Reminder Lead Time**: +The interval between a Reset Reminder and the Next Known Expiry. The default is 24 hours. +_Avoid_: Trigger time, auto-use window + +**Next Known Expiry**: +The earliest expiry among the banked-reset details currently available to the product. It is not necessarily the earliest expiry across all banked resets when detail coverage is incomplete. +_Avoid_: Oldest reset expiry + +**Reset Detail Coverage**: +The number of banked-reset expiry details available compared with the authoritative reset count. The product shows incomplete coverage next to the reset count. +_Avoid_: Hidden details, reset confidence + +**Local Activity**: +Codex work observed on the user’s machine, including task, agent, model, token, context, and tool activity when available. +_Avoid_: Billed usage, account charge + +**Active Turn**: +A Codex turn between its observed start and completion. Waiting and polling remain part of the turn but are classified separately from execution. +_Avoid_: Open thread, foreground window + +**Active Task Tree**: +A Task Tree in which at least one observable turn is active. +_Avoid_: Open project, open conversation + +**Active Time**: +Observed elapsed time in the current Allowance Window during which at least one Active Task Tree is active. Overlapping activity counts once. Execution and waiting are separated when the source allows it. +_Avoid_: Compute time, billed time + +**Estimated Active Time Available**: +A confidence-bounded range for additional Active Time before the current allowance is exhausted, based on recent comparable work. It appears only when Local Coverage is high enough. +_Avoid_: Runtime per week, guaranteed hours + +**Concurrency**: +The number of Active Task Trees at a point in time. +_Avoid_: Open threads, running agents + +**Project**: +The folder or project group presented by Codex, shown with the same short name. The product reuses this hierarchy and does not infer, rename, or replace it. +_Avoid_: Custom workspace, project alias + +**Task**: +A root Codex task as presented by Codex. It is the stable root of a usage receipt. +_Avoid_: Project, inferred topic + +**Task Tree**: +A Task together with the descendant agent tasks whose relationships are observable. +_Avoid_: Session when referring to the full hierarchy + +**Usage Receipt**: +A factual summary of Local Activity for a Task Tree, with drill-down to agents and turns and kept distinct from Account Movement. +_Avoid_: Cost receipt, billing receipt + +**Runway**: +The estimated time until the current allowance is exhausted at the Account Movement pace observed over the latest Rolling 24-hour Interval, or its shorter available portion. +_Avoid_: Runtime entitlement, hours per week + +**Current Estimate**: +The future Usage Remaining projection from the latest account reading using the same recent pace as Runway. It is a derived estimate, never an observed account value. +_Avoid_: Actual usage, projection using a different pace from Runway + +**Suggested Pace**: +The non-negative maximum future rate of allowance consumption, expressed in percentage points per day and derived from current Usage Remaining, time to reset, and the chosen safety buffer. It does not require historical pace observations; zero means the buffer has been reached. +_Avoid_: Official quota, guaranteed pace + +**Insight**: +A structured observation or recommendation supported by named evidence, freshness, coverage, and confidence. +_Avoid_: Tip, verdict + +**Usage Deviation**: +An observed usage rate outside the user’s personal reference range under comparable conditions. It appears as a passive Insight with the measured change, comparison period, Coverage, and Confidence, without claiming a cause. +_Avoid_: Anomaly, billing error, limit reduction + +**Deterministic Insight**: +An Insight produced from local facts and explicit rules without invoking a model. +_Avoid_: AI insight, generated insight + +**Codex-assisted Insight**: +A user-requested interpretation generated by Codex from bounded evidence. It sends a request to Codex and consumes the user’s allowance. +_Avoid_: Local insight, automatic insight + +**Insight Execution Profile**: +The exact model and reasoning level used for a Codex-assisted Insight. The required initial profile is GPT-5.6 Luna Medium; when the current model catalog does not advertise that exact profile, the action is not shown and no fallback runs. +_Avoid_: Task model, inherited model + +**Metadata-only Analysis**: +A Codex-assisted analysis whose evidence excludes Source Content. The user’s explicit analyze action is sufficient authorization to start it. +_Avoid_: Local analysis + +**Source-backed Analysis**: +A Codex-assisted analysis whose evidence includes Source Content. It requires a preflight that identifies the content categories that will be sent. +_Avoid_: Full-context analysis + +**Analytics Overhead**: +Local Activity from a Codex-assisted request and the bounded Account Movement observed during it. The product does not claim that concurrent account movement was caused by the analysis. +_Avoid_: Free analysis, background usage + +**Coverage**: +The degree to which the expected source data for a metric or insight was available. +_Avoid_: Accuracy + +**Confidence**: +The product’s assessment of how strongly the available evidence supports a derived metric or insight. +_Avoid_: Certainty + +**Unavailable Reason**: +The shortest specific explanation of why a value cannot be calculated, such as a missing boundary reading, reset or correction, or account change. +_Avoid_: Not enough data without a reason, coverage jargon in the primary message + +**Evidence Details**: +An optional drill-down containing a value's source, Observed Interval, Coverage, Confidence, and caveats. These details remain available without cluttering the primary result. +_Avoid_: Evidence metadata as the primary message + +**Unattributed Movement**: +Account Movement that cannot be associated with observed Local Activity. +_Avoid_: Hidden charge, unexplained billing diff --git a/Package.swift b/Package.swift index da46f5a..8fd9642 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,11 @@ let package = Package( name: "CodexLimits", platforms: [.macOS(.v14)], products: [ - .executable(name: "CodexLimits", targets: ["CodexLimits"]) + .executable(name: "CodexLimits", targets: ["CodexLimits"]), + .executable( + name: "CodexLimitsClaudeRelay", + targets: ["CodexLimitsClaudeRelay"] + ) ], dependencies: [ .package( @@ -17,11 +21,16 @@ let package = Package( targets: [ .executableTarget( name: "CodexLimits", - dependencies: ["Sparkle"] + dependencies: ["ClaudeIntegrationCore", "Sparkle"] + ), + .target(name: "ClaudeIntegrationCore"), + .executableTarget( + name: "CodexLimitsClaudeRelay", + dependencies: ["ClaudeIntegrationCore"] ), .testTarget( name: "CodexLimitsTests", - dependencies: ["CodexLimits"], + dependencies: ["ClaudeIntegrationCore", "CodexLimits"], resources: [.copy("Fixtures")] ) ] diff --git a/README.md b/README.md index 1c46c8b..241cc47 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## What it shows -Codex shows Usage remaining. Codex Limits shows when it resets, how it changed, and which local Tasks this Mac observed. +Codex shows Usage remaining. Codex Limits shows when it resets, how it changed, and which local Tasks this Mac observed. Version 0.3.0 also includes opt-in Claude Code (Experimental) and Grok (Beta) Integrations with Usage remaining history. Claude records its seven-day and five-hour allowances during normal activity; Grok records its returned weekly or monthly usage pool. OpenCode remains deferred. Open the menu to see: @@ -33,12 +33,12 @@ Open the menu to see: - Account and local token activity. - Active time, concurrency, and Usage Receipts for the Task Trees this Mac can read. - Checks that run on this Mac and an optional `Analyze with Codex` action. +- Claude Code's last observed seven-day and five-hour Usage remaining, with recorded history when that Experimental Integration is enabled and an eligible Pro or Max account supplies the data. +- Grok Usage remaining, recorded history, its reset, and available plan, prepaid, and pay-as-you-go facts when Grok Beta is enabled. -Switch among three views: +Choose `All` for compact current-window charts beside each Integration’s remaining allowance and reset. Blue shows recorded usage remaining; the green dashed line shows the target. Select a row to open its detail. -- **Graphs** — Usage remaining, Token activity, Usage per token, and Concurrency. -- **Facts** — account facts, banked resets, Other limits, Active Time, and Usage Receipts. -- **Insights** — local checks, saved observations, and analysis you ask Codex to run. +Codex, Claude Code, and Grok share the same detail layout: Integration name, remaining allowance, reset, and chart. Codex keeps pace and runway under `Usage details`. Its `More` menu opens Token activity, Facts and reset reminders, or Insights. @@ -61,6 +61,8 @@ Codex Limits keeps three kinds of values separate: The app keeps weak estimates out of guidance and Insights. The Usage remaining chart may still show a Current or Past estimate when it has enough fresh points to show a useful direction. The chart names its source, Coverage, and Confidence. +Claude and Grok charts show actual observations recorded on this Mac. They start with available data; an older latest-only cache contributes one point. Their current estimates need at least two compatible observations separated by a minute, a fresh latest reading, and no gap over thirty minutes, reset, or correction. Token counts never stand in for an allowance reading. + ## Features - Uses the weekly Codex limit as the main Usage remaining value. @@ -74,18 +76,20 @@ The app keeps weak estimates out of guidance and Insights. The Usage remaining c - Asks Codex to analyze selected data only after you click an analysis button. - Lists the selected Source Content types—prompts, responses, code, paths, commands, and tool output—before you send them to Codex. - Copies account usage samples to a private folder that you choose. -- Deletes all Codex Limits analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`. -- Refreshes on launch, after wake, when you open the menu, every ten minutes, or on request. +- Deletes Codex analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`. +- Refreshes Codex at launch, after wake, and every ten minutes only when its weekly metric is selected; visible or explicit reads remain bounded. Grok uses a ten-minute cadence only while selected for the menu bar, backs off after failures, and performs due reads when visible. Claude Code is event-driven and adds no polling timer. - Runs as a native SwiftUI menu-bar app and uses Sparkle to verify and install signed updates. - Does not redeem resets, change Codex settings, or control Tasks. ## How it works -1. Codex Limits starts your installed Codex CLI and reads account data through its local app server. +1. Codex Limits starts your installed Codex CLI and reads account data through its local app server when Codex has demand. 2. It reads local Codex records without taking control of a Task. -3. It stores small history files on your Mac and keeps each account separate. -4. It uses those sources to make charts, facts, and Insights. -5. It sends a request to Codex only when you choose an `Analyze with Codex` action. +3. If you explicitly set up Claude Code Experimental, Claude Code sends bounded allowance fields to a short-lived local helper during normal Claude activity; Codex Limits does not prompt Claude or poll it. +4. If you enable Grok Beta, the app reads billing through your official Grok Build CLI. The CLI manages its own login and service connection; no prompt or coding session is created. +5. It stores compact history files on your Mac and keeps each Codex account separate. Claude and Grok each retain their own local observation history until you delete it; their active charts read a bounded view of the latest 84 days. +6. It uses those sources to make provider-specific cards without combining their allowances. +7. It sends an analysis request to Codex only when you choose an `Analyze with Codex` action. Coverage says how much needed data the app saw. Confidence says how well that data supports an estimate. Low-confidence chart lines do not change guidance or Insights. @@ -94,6 +98,8 @@ Coverage says how much needed data the app saw. Confidence says how well that da Codex Limits keeps analytics local by default: - It does not copy or store your Codex credentials. +- It does not read or store Claude credentials, prompts, responses, session identifiers, model names, transcripts, or project paths. Claude setup changes only the user status line after confirmation and never overwrites an existing status line. +- Grok reads use the official CLI’s supported ACP extension. Codex Limits does not read Grok credentials or cookies, call its private billing backend directly, or store raw CLI output. - It sends no usage data to this project or its author. - It stores account readings and local summaries in the app's Application Support directory. - It does not copy prompts, responses, code, paths, commands, or tool output into Analytics History. @@ -102,10 +108,11 @@ Codex Limits keeps analytics local by default: - `Analyze Source Content` shows each content type before you send it. - Each request to Codex uses your Codex allowance. The buttons appear only when Codex offers the required model and reasoning level. - Reset reminders use local macOS notifications. The app asks for permission when you first enable the reminder. -- If you enable history sync, it copies only usage samples to the selected folder. Preferences, credentials, and raw Codex responses stay on your Mac. +- If you enable history sync, it copies only Codex usage samples to the selected folder. Claude and Grok history, preferences, credentials, and raw Codex responses stay on your Mac. - Synced JSON files contain observation times, remaining percentages, and reset times. Choose a folder that you do not share with other people. -- `Delete analytics history` removes Codex Limits history on this Mac and in the selected sync folder. It keeps your preferences and source Codex records. -- The Codex CLI contacts the Codex service during normal account reads and user-requested Codex analysis. +- `Delete analytics history` removes Codex analytics history on this Mac and in the selected sync folder. It keeps your preferences and source Codex records. +- The Codex CLI contacts the Codex service during normal account reads and user-requested Codex analysis. Grok Build contacts its service during enabled usage reads. +- `Delete Claude Code data…` and `Delete Grok data…` disable that Integration and remove its app-owned history, snapshot, and setup or executable preference. The integrated product's own records and login remain intact. Do not attach raw CLI output or screenshots containing account usage to public issues. @@ -113,9 +120,11 @@ Do not attach raw CLI output or screenshots containing account usage to public i - macOS 14 or later - Xcode 16.4 or later -- A signed-in, Homebrew-managed Codex CLI at `/opt/homebrew/bin/codex` or `/usr/local/bin/codex` +- A signed-in standalone Codex CLI to use the Codex Integration. Known Homebrew and native installer locations are detected, and Settings offers `Locate…` for another executable path. +- Claude Code is optional. Its Experimental allowance card requires explicit setup and an eligible Pro or Max account; Free can run Claude Code but does not provide the required allowance fields. +- Grok Build is optional. Its Beta allowance card requires a compatible official CLI and a Grok login with available allowance data. Version 1.0.25 passed a real billing read on 2026-09-10. -Codex Limits does not use a Codex binary bundled with another app. Install and update the standalone CLI yourself. +Codex Limits does not use a Codex binary bundled with another app. Install and update each standalone CLI yourself. OpenCode is not included in v1. ## Build from source @@ -136,13 +145,18 @@ Stable releases include a universal app for Apple Silicon and Intel. The app is ## Test ```sh -swift test +swift test -c release ``` The tests use made-up usage data. Do not commit exported account data or local app state as test data. +For a local Grok check, build and open the app, enable `Grok` in Settings, and select `Grok — Current-period usage remaining`. Check the Grok detail and `All` views, wait at least 30 seconds before an explicit refresh, then disable Grok and confirm its menu value disappears. See the [Grok validation note](docs/research/grok-build-validation-2026-09-10.md) for expected behavior and remaining release checks. + ## Current limitations +- Multi-integration release acceptance remains pending: the all-enabled idle comparison and eight-hour lifecycle soak are incomplete. The live Claude observation is explicitly waived for the Experimental Integration. Local development testing can proceed. +- Claude and Grok sources do not provide a stable account identity. Their histories describe this local installation, do not sync, and cannot reconstruct usage from before observations were recorded. + - Existing 0.2.6 and older installations require one final manual update to a version that includes the in-app updater. - Account and local values can differ because this Mac may not observe every Codex Task. - Estimates need account readings near both ends of a time range and enough similar local work. diff --git a/Resources/Info.plist b/Resources/Info.plist index e86d45a..a247c42 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageTypeAPPLCFBundleShortVersionString - 0.2.8 + 0.3.0CFBundleVersion - 9 + 10LSApplicationCategoryTypepublic.app-category.developer-toolsLSMinimumSystemVersion diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index c9e2a33..0c27404 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -25,24 +25,32 @@ if [[ ${CODEX_LIMITS_UNIVERSAL:-0} == 1 ]]; then arm_release="$project_dir/.build/universal-arm64/arm64-apple-macosx/release" intel_release="$project_dir/.build/universal-x86_64/x86_64-apple-macosx/release" executable="$project_dir/.build/release/CodexLimits" + claude_relay="$project_dir/.build/release/CodexLimitsClaudeRelay" mkdir -p "${executable:h}" lipo -create \ "$arm_release/CodexLimits" \ "$intel_release/CodexLimits" \ -output "$executable" + lipo -create \ + "$arm_release/CodexLimitsClaudeRelay" \ + "$intel_release/CodexLimitsClaudeRelay" \ + -output "$claude_relay" framework="$arm_release/Sparkle.framework" else xcrun swift build "${build_args[@]}" executable="$project_dir/.build/release/CodexLimits" + claude_relay="$project_dir/.build/release/CodexLimitsClaudeRelay" framework="$project_dir/.build/release/Sparkle.framework" fi rm -rf "$app_dir" mkdir -p \ "$app_dir/Contents/MacOS" \ + "$app_dir/Contents/Helpers" \ "$app_dir/Contents/Resources" \ "$app_dir/Contents/Frameworks" cp "$executable" "$app_dir/Contents/MacOS/CodexLimits" +cp "$claude_relay" "$app_dir/Contents/Helpers/CodexLimitsClaudeRelay" install_name_tool -add_rpath \ @loader_path/../Frameworks \ "$app_dir/Contents/MacOS/CodexLimits" diff --git a/Scripts/check-grok-exit.py b/Scripts/check-grok-exit.py new file mode 100644 index 0000000..4e46781 --- /dev/null +++ b/Scripts/check-grok-exit.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Check normal app-exit cleanup with fake Grok processes; run with python3.""" + +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + + +def alive(pid): + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix="codex-limits-grok-exit-") as temporary: + work = Path(temporary) + main = work / "main.swift" + main.write_text("""import Darwin +import Foundation +@main struct ExitProbe { + static func main() async { + let executable = URL(fileURLWithPath: CommandLine.arguments[1]) + let record = CommandLine.arguments[2] + Task { _ = try? await GrokBillingClient().fetch(executableURL: executable) } + let deadline = ProcessInfo.processInfo.systemUptime + 3 + while !FileManager.default.fileExists(atPath: record) + && ProcessInfo.processInfo.systemUptime < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + guard FileManager.default.fileExists(atPath: record) else { exit(2) } + exit(0) + } +} +""") + parent = work / "parent" + subprocess.run([ + "/usr/bin/xcrun", "swiftc", "-parse-as-library", + "-module-cache-path", str(work / "modules"), + str(root / "Sources/CodexLimits/GrokBillingClient.swift"), + str(main), "-o", str(parent), + ], check=True, timeout=120) + + for mode in ("graceful", "forced"): + record = work / f"{mode}.pids" + fake = work / f"fake-{mode}" + setup = "trap '' TERM\n" if mode == "forced" else "" + cleanup = "" if mode == "forced" else ( + "trap 'kill \"$child\" 2>/dev/null; wait \"$child\" 2>/dev/null; exit 0' TERM\n" + ) + fake.write_text( + "#!/bin/sh\n" + setup + "/bin/sleep 60 &\nchild=$!\n" + cleanup + + 'printf \'%s %s\\n\' "$$" "$child" > "$GROK_EXIT_CHECK_RECORD"\n' + + 'wait "$child"\n' + ) + fake.chmod(0o700) + environment = {**os.environ, "GROK_EXIT_CHECK_RECORD": str(record)} + process = subprocess.Popen( + [str(parent), str(fake), str(record)], env=environment, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + started = time.monotonic() + try: + assert process.wait(timeout=5) == 0, f"{mode}: parent failed" + elapsed = time.monotonic() - started + assert elapsed < 3, f"{mode}: exit took {elapsed:.3f}s" + pids = [int(value) for value in record.read_text().split()] + assert len(pids) == 2, f"{mode}: missing fake process IDs" + deadline = time.monotonic() + 1 + while any(alive(pid) for pid in pids) and time.monotonic() < deadline: + time.sleep(0.01) + assert not any(alive(pid) for pid in pids), f"{mode}: orphaned fake process" + print(f"{mode}: normal exit in {elapsed:.3f}s; CLI and child absent") + finally: + if process.poll() is None: + process.kill() + process.wait() + if record.exists(): + recorded = [int(value) for value in record.read_text().split()] + if any(alive(pid) for pid in recorded): + try: + os.killpg(recorded[0], signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/Scripts/measure-app-idle.sh b/Scripts/measure-app-idle.sh new file mode 100755 index 0000000..2fd12a5 --- /dev/null +++ b/Scripts/measure-app-idle.sh @@ -0,0 +1,46 @@ +#!/bin/zsh +set -euo pipefail + +if [[ ${1:-} == --self-test ]]; then + output=$(mktemp /private/tmp/codex-limits-idle-self-test.XXXXXX.csv) + trap 'rm -f "$output"' EXIT + "$0" $$ 2 1 "$output" >/dev/null + [[ $(wc -l < "$output") -eq 3 ]] + print "Idle sampler checks passed" + exit +fi + +pid=${1:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +duration=${2:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +interval=${3:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} +output=${4:?"Usage: $0 PID DURATION_SECONDS INTERVAL_SECONDS OUTPUT.csv"} + +[[ $pid == <-> && $pid -gt 0 ]] +[[ $duration == <-> && $duration -gt 0 ]] +[[ $interval == <-> && $interval -gt 0 && $interval -le 60 ]] +kill -0 "$pid" + +samples=$(( (duration + interval - 1) / interval )) +print 'timestamp,parent_rss_kib,parent_cpu_percent,child_count,child_rss_kib,child_cpu_percent' > "$output" + +for ((sample = 1; sample <= samples; sample++)); do + parent=$(/bin/ps -o rss=,%cpu= -p "$pid" | awk '{$1=$1; print}') + [[ -n $parent ]] || { + print -u2 "Process $pid ended after $((sample - 1)) samples" + exit 66 + } + read -r parent_rss parent_cpu <<< "$parent" + children=$(/bin/ps -axo ppid=,rss=,%cpu= | awk -v parent="$pid" ' + $1 == parent { count += 1; rss += $2; cpu += $3 } + END { printf "%d %d %.3f", count, rss, cpu } + ') + read -r child_count child_rss child_cpu <<< "$children" + print "$(date +%s),$parent_rss,$parent_cpu,$child_count,$child_rss,$child_cpu" >> "$output" + + if (( sample % 6 == 0 || sample == samples )); then + print -u2 "sample $sample/$samples rss=${parent_rss}KiB cpu=${parent_cpu}% children=$child_count" + fi + if (( sample < samples )); then + sleep "$interval" + fi +done diff --git a/Scripts/validate-release.sh b/Scripts/validate-release.sh index 2111c9b..6a1870d 100755 --- a/Scripts/validate-release.sh +++ b/Scripts/validate-release.sh @@ -9,11 +9,47 @@ is_newer_than() { ! is-at-least "$candidate" "$previous" } +is_accepted_multi_integration_status() { + [[ $1 == 'Status: Accepted for v1 implementation' ]] +} + +release_gate_accepted() { + local gate=$1 + local version=$2 + awk -F '|' -v gate="$gate" -v waiver="Waived for $version" ' + function trim(value) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + return value + } + trim($2) == gate { + found = 1 + passed = trim($4) == "Passed" || trim($4) == waiver + } + END { exit !(found && passed) } + ' +} + if [[ ${1:-} == --self-test ]]; then is_newer_than 0.2.7 0.2.6 - ! is_newer_than 0.2.7 0.2.7 - ! is_newer_than 0.2.7 0.2.8 - print "Release version checks passed" + ! is_newer_than 0.2.7 0.2.7 || exit 1 + ! is_newer_than 0.2.7 0.2.8 || exit 1 + is_accepted_multi_integration_status \ + 'Status: Accepted for v1 implementation' + ! is_accepted_multi_integration_status \ + 'Status: Needs revision — release gates remain' || exit 1 + release_gate_accepted 'All-enabled idle comparison' 0.3.0 <<< \ + '| All-enabled idle comparison | Recorded evidence | Passed |' + ! release_gate_accepted 'All-enabled idle comparison' 0.3.0 <<< \ + '| All-enabled idle comparison | Not run | Pending |' || exit 1 + ! release_gate_accepted 'Eight-hour mixed lifecycle soak' 0.3.0 <<< \ + '| Another gate | Recorded evidence | Passed |' || exit 1 + release_gate_accepted 'Eight-hour mixed lifecycle soak' 0.3.0 <<< \ + '| Eight-hour mixed lifecycle soak | Owner decision | Waived for 0.3.0 |' + ! release_gate_accepted 'Eight-hour mixed lifecycle soak' 0.3.1 <<< \ + '| Eight-hour mixed lifecycle soak | Owner decision | Waived for 0.3.0 |' || exit 1 + ! release_gate_accepted 'Eight-hour mixed lifecycle soak' 0.3.0 <<< \ + '| Eight-hour mixed lifecycle soak | Unscoped exception | Waived |' || exit 1 + print "Release validator checks passed" exit fi @@ -24,6 +60,20 @@ version=${1:?"Usage: $0 VERSION"} } project_dir=${0:A:h:h} +multi_integration_prd="$project_dir/docs/prd/multi-integration-workspace.md" +multi_integration_status=$(sed -n '/^Status: /{p;q;}' "$multi_integration_prd") +is_accepted_multi_integration_status "$multi_integration_status" || { + print -u2 "Multi-integration v1 release gates are not accepted" + exit 65 +} +for gate in \ + 'All-enabled idle comparison' \ + 'Eight-hour mixed lifecycle soak'; do + release_gate_accepted "$gate" "$version" < "$multi_integration_prd" || { + print -u2 "Multi-integration release gate is not accepted for $version: $gate" + exit 65 + } +done plist="$project_dir/Resources/Info.plist" plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist") diff --git a/Sources/ClaudeIntegrationCore/AllowanceHistory.swift b/Sources/ClaudeIntegrationCore/AllowanceHistory.swift new file mode 100644 index 0000000..0a9baef --- /dev/null +++ b/Sources/ClaudeIntegrationCore/AllowanceHistory.swift @@ -0,0 +1,267 @@ +import Darwin +import Foundation + +public struct AllowanceObservation: Codable, Equatable, Hashable, Sendable { + public let metric: String + public let observedAt: Date + public let remainingPercent: Double + public let resetsAt: Date + public let startsAt: Date? + public let source: String? + + public init( + metric: String, + observedAt: Date, + remainingPercent: Double, + resetsAt: Date, + startsAt: Date? = nil, + source: String? = nil + ) { + self.metric = metric + self.observedAt = observedAt + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.startsAt = startsAt + self.source = source + } + + public var isValid: Bool { + hasValidFields && observedAt < resetsAt + } + + fileprivate var hasValidFields: Bool { + !metric.isEmpty && metric.utf8.count <= 64 + && metric.utf8.allSatisfy { + (48 ... 57).contains($0) || (65 ... 90).contains($0) + || (97 ... 122).contains($0) || $0 == 45 || $0 == 95 + } + && Self.validDate(observedAt) && Self.validDate(resetsAt) + && remainingPercent.isFinite && (0 ... 100).contains(remainingPercent) + && (startsAt.map { Self.validDate($0) && $0 <= observedAt } ?? true) + && (source.map { + !$0.isEmpty && $0.utf8.count <= 64 && $0.utf8.allSatisfy { + (48 ... 57).contains($0) || (65 ... 90).contains($0) + || (97 ... 122).contains($0) || [45, 46, 95].contains($0) + } + } ?? true) + } + + static func validDate(_ date: Date) -> Bool { + let seconds = date.timeIntervalSince1970 + return seconds.isFinite && (0 ..< 253_402_300_800).contains(seconds) + } +} + +public enum AllowanceHistoryError: Error, Equatable { + case invalidRecord + case readLimitExceeded + case writeFailed +} + +public enum AllowanceHistory { + public static let retainedViewDays = 84 + public static let maximumFileBytes = 4 * 1_024 * 1_024 + public static let maximumReadBytes = 32 * 1_024 * 1_024 + public static let maximumRecordBytes = 512 + public static let maximumReadRecords = 200_000 + public static let maximumObservationClockSkew: TimeInterval = 60 + private static let maximumAppendRecords = 64 + + public static func append( + _ observations: [AllowanceObservation], + in directory: URL, + now: () -> Date = { Date() } + ) throws { + guard observations.count <= maximumAppendRecords else { + throw AllowanceHistoryError.invalidRecord + } + guard observations.allSatisfy(\.hasValidFields) else { + throw AllowanceHistoryError.invalidRecord + } + let observations = observations.filter { $0.observedAt < $0.resetsAt } + guard !observations.isEmpty else { return } + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: directory.path + ) + let days = Dictionary(grouping: observations, by: { dayName($0.observedAt) }) + for day in days.keys.sorted() { + try appendDay(days[day]!.sorted(by: ordered), to: directory.appendingPathComponent(day), now: now) + } + } + + public static func read(in directory: URL, now: Date, since: Date? = nil) throws -> [AllowanceObservation] { + guard AllowanceObservation.validDate(now), + since.map(AllowanceObservation.validDate) ?? true else { + throw AllowanceHistoryError.invalidRecord + } + let cutoff = max(since ?? Date(timeIntervalSince1970: 0), + now.addingTimeInterval(-Double(retainedViewDays) * 86_400)) + guard cutoff <= now else { return [] } + var day = calendar.startOfDay(for: cutoff) + var totalBytes = 0 + var recordCount = 0 + var observations: Set = [] + while day <= now { + try Task.checkCancellation() + let name = dayName(day) + let file = directory.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: file.path) { + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + var data = Data() + while let chunk = try handle.read(upToCount: min(64 * 1_024, maximumFileBytes + 1 - data.count)), + !chunk.isEmpty { + data.append(chunk) + guard data.count <= maximumFileBytes, + totalBytes + data.count <= maximumReadBytes else { + throw AllowanceHistoryError.readLimitExceeded + } + try Task.checkCancellation() + } + totalBytes += data.count + var lineStart = data.startIndex + for newline in data.indices where data[newline] == 10 { + let line = data[lineStart ..< newline] + lineStart = data.index(after: newline) + recordCount += 1 + guard recordCount <= maximumReadRecords else { + throw AllowanceHistoryError.readLimitExceeded + } + let observation = try decode(line) + guard dayName(observation.observedAt) == name else { + throw AllowanceHistoryError.invalidRecord + } + if observation.observedAt >= cutoff, observation.observedAt <= now { + observations.insert(observation) + } + } + if let end = data.lastIndex(of: 10) { + guard data.distance(from: data.index(after: end), to: data.endIndex) <= maximumRecordBytes else { + throw AllowanceHistoryError.invalidRecord + } + } else if data.count > maximumRecordBytes { + throw AllowanceHistoryError.invalidRecord + } + } + day = day.addingTimeInterval(86_400) + } + return observations.sorted(by: ordered) + } + + public static func delete(in directory: URL) throws { + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + + private static func appendDay(_ observations: [AllowanceObservation], to url: URL, now: () -> Date) throws { + let descriptor = open(url.path, O_CREAT | O_RDWR | O_APPEND, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(descriptor) } + let lockDeadline = ProcessInfo.processInfo.systemUptime + 0.25 + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + try Task.checkCancellation() + guard errno == EWOULDBLOCK || errno == EINTR, + ProcessInfo.processInfo.systemUptime < lockDeadline else { + throw AllowanceHistoryError.writeFailed + } + usleep(10_000) + } + defer { flock(descriptor, LOCK_UN) } + guard fchmod(descriptor, S_IRUSR | S_IWUSR) == 0 else { throw AllowanceHistoryError.writeFailed } + let size = lseek(descriptor, 0, SEEK_END) + guard size >= 0 else { throw AllowanceHistoryError.writeFailed } + let tailCount = min(Int(size), (maximumAppendRecords + 2) * maximumRecordBytes) + var bytes = [UInt8](repeating: 0, count: tailCount) + let count = pread(descriptor, &bytes, tailCount, size - off_t(tailCount)) + guard count == tailCount else { throw AllowanceHistoryError.writeFailed } + var tail = Data(bytes) + if !tail.isEmpty, tail.last != 10 { + let partialStart = tail.lastIndex(of: 10).map { tail.index(after: $0) } ?? tail.startIndex + let partial = tail[partialStart...] + guard partial.count <= maximumRecordBytes else { throw AllowanceHistoryError.invalidRecord } + if (try? decode(partial)) != nil { + try write(Data([10]), to: descriptor) + tail.append(10) + } else { + guard ftruncate(descriptor, size - off_t(partial.count)) == 0 else { + throw AllowanceHistoryError.writeFailed + } + tail.removeSubrange(partialStart...) + } + } + if size > tailCount, let newline = tail.firstIndex(of: 10) { + tail.removeSubrange(...newline) + } + let recent = try completeLines(tail).map(decode) + guard recent.allSatisfy({ dayName($0.observedAt) == url.lastPathComponent }) else { + throw AllowanceHistoryError.invalidRecord + } + var seen = Set(recent) + // Read the wall clock after acquiring the lock: queued writers may wait + // while a newer observation is committed. Future records from a clock + // rollback remain on disk but cannot suppress current observations. + let latestAcceptedTime = now().addingTimeInterval(maximumObservationClockSkew) + var latest = Dictionary(grouping: recent.filter { $0.observedAt <= latestAcceptedTime }, by: \.metric).mapValues { + $0.map(\.observedAt).max()! + } + // ponytail: ordered sources need only bounded-tail retry deduplication; + // arbitrary historical imports would need an indexed merge path. + let encoder = JSONEncoder() + for observation in observations { + guard latest[observation.metric].map({ observation.observedAt >= $0 }) ?? true, + seen.insert(observation).inserted else { continue } + var data = try encoder.encode(observation) + guard data.count <= maximumRecordBytes else { throw AllowanceHistoryError.invalidRecord } + data.append(10) + try write(data, to: descriptor) + latest[observation.metric] = observation.observedAt + } + } + + private static func write(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { bytes in + var written = 0 + while written < bytes.count { + let count = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: written), bytes.count - written) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw AllowanceHistoryError.writeFailed } + written += count + } + } + } + + private static func completeLines(_ data: Data) -> [Data.SubSequence] { + guard let last = data.lastIndex(of: 10) else { return [] } + return data[.. AllowanceObservation { + guard !line.isEmpty, line.count <= maximumRecordBytes, + let value = try? JSONDecoder().decode(AllowanceObservation.self, from: line), + value.isValid else { throw AllowanceHistoryError.invalidRecord } + return value + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func dayName(_ date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d.jsonl", parts.year!, parts.month!, parts.day!) + } + + private static func ordered(_ lhs: AllowanceObservation, _ rhs: AllowanceObservation) -> Bool { + if lhs.observedAt != rhs.observedAt { return lhs.observedAt < rhs.observedAt } + if lhs.metric != rhs.metric { return lhs.metric < rhs.metric } + if lhs.resetsAt != rhs.resetsAt { return lhs.resetsAt < rhs.resetsAt } + return lhs.remainingPercent < rhs.remainingPercent + } +} diff --git a/Sources/ClaudeIntegrationCore/ClaudeRelay.swift b/Sources/ClaudeIntegrationCore/ClaudeRelay.swift new file mode 100644 index 0000000..d8201b4 --- /dev/null +++ b/Sources/ClaudeIntegrationCore/ClaudeRelay.swift @@ -0,0 +1,328 @@ +import Darwin +import Foundation + +public struct ClaudeAllowanceWindowSnapshot: Codable, Equatable, Sendable { + public let remainingPercent: Double + public let resetsAt: Date + + public init(remainingPercent: Double, resetsAt: Date) { + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + } +} + +public struct ClaudeAllowanceSnapshot: Codable, Equatable, Sendable { + public let version: Int + public let observedAt: Date + public let cliVersion: String? + public let fiveHour: ClaudeAllowanceWindowSnapshot? + public let sevenDay: ClaudeAllowanceWindowSnapshot? + public let historyWriteFailed: Bool? + + public init( + version: Int = 1, + observedAt: Date, + cliVersion: String?, + fiveHour: ClaudeAllowanceWindowSnapshot?, + sevenDay: ClaudeAllowanceWindowSnapshot?, + historyWriteFailed: Bool? = nil + ) { + self.version = version + self.observedAt = observedAt + self.cliVersion = cliVersion + self.fiveHour = fiveHour + self.sevenDay = sevenDay + self.historyWriteFailed = historyWriteFailed + } + + public var historyObservations: [AllowanceObservation] { + [ + ("claude-seven-day", sevenDay, 7 * 86_400.0), + ("claude-five-hour", fiveHour, 5 * 3_600.0) + ].compactMap { metric, window, duration in + guard let window else { return nil } + let observation = AllowanceObservation( + metric: metric, + observedAt: observedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + startsAt: window.resetsAt.addingTimeInterval(-duration), + source: "statusLine.rate_limits" + ) + return observation.isValid ? observation : nil + } + } +} + +public enum ClaudeRelayError: Error, Equatable { + case inputTooLarge + case invalidInput + case noAllowance + case disabled + case lockUnavailable +} + +public enum ClaudeRelay { + public static let snapshotChangedNotificationName = + "com.github.thrr87.CodexLimits.ClaudeSnapshotChanged" + public static let maximumInputBytes = 256 * 1_024 + public static let maximumCacheBytes = 64 * 1_024 + public static let equivalentWriteInterval: TimeInterval = 30 + public static let unavailableStatusLine = "Usage unavailable" + + public static func readBoundedInput( + from handle: FileHandle + ) throws -> Data { + var input = Data() + while input.count < maximumInputBytes + 1 { + let remaining = maximumInputBytes + 1 - input.count + guard let chunk = try handle.read( + upToCount: min(64 * 1_024, remaining) + ), !chunk.isEmpty else { + break + } + input.append(chunk) + } + return input + } + + public static func decode( + _ data: Data, + observedAt: Date + ) throws -> ClaudeAllowanceSnapshot { + guard data.count <= maximumInputBytes else { + throw ClaudeRelayError.inputTooLarge + } + let input: Input + do { + input = try JSONDecoder().decode(Input.self, from: data) + } catch { + throw ClaudeRelayError.invalidInput + } + let fiveHour = try window(input.rateLimits?.fiveHour) + let sevenDay = try window(input.rateLimits?.sevenDay) + guard fiveHour != nil || sevenDay != nil else { + throw ClaudeRelayError.noAllowance + } + return ClaudeAllowanceSnapshot( + observedAt: observedAt, + cliVersion: acceptedVersion(input.version), + fiveHour: fiveHour, + sevenDay: sevenDay + ) + } + + public static func statusLine( + for snapshot: ClaudeAllowanceSnapshot + ) -> String { + var values: [String] = [] + if let sevenDay = snapshot.sevenDay { + values.append( + "7d \(Int(sevenDay.remainingPercent.rounded()))% remaining" + ) + } + if let fiveHour = snapshot.fiveHour { + values.append( + "5h \(Int(fiveHour.remainingPercent.rounded()))% remaining" + ) + } + return values.joined(separator: " · ") + } + + public static func storeIfNewer( + _ snapshot: ClaudeAllowanceSnapshot, + at cacheURL: URL, + enabledMarkerURL: URL, + now: () -> Date = { Date() } + ) throws -> Bool { + guard let markerIdentity = enabledMarkerIdentity(at: enabledMarkerURL) else { + throw ClaudeRelayError.disabled + } + return try withCacheLock(at: cacheURL, waits: true) { + guard enabledMarkerIdentity(at: enabledMarkerURL) == markerIdentity else { + throw ClaudeRelayError.disabled + } + let wallTime = now() + guard snapshot.observedAt <= wallTime.addingTimeInterval(AllowanceHistory.maximumObservationClockSkew) else { + throw ClaudeRelayError.invalidInput + } + let existing = try? readSnapshot(at: cacheURL, now: wallTime) + if let existing { + if existing.observedAt >= snapshot.observedAt { + return false + } + if existing.cliVersion == snapshot.cliVersion, + existing.fiveHour == snapshot.fiveHour, + existing.sevenDay == snapshot.sevenDay, + snapshot.observedAt.timeIntervalSince(existing.observedAt) + < equivalentWriteInterval { + return false + } + } + var historyFailed = false + do { + try AllowanceHistory.append( + (existing?.historyObservations ?? []) + snapshot.historyObservations, + in: historyDirectory(for: cacheURL), now: now + ) + } catch { + historyFailed = true + } + let stored = ClaudeAllowanceSnapshot( + version: snapshot.version, + observedAt: snapshot.observedAt, + cliVersion: snapshot.cliVersion, + fiveHour: snapshot.fiveHour, + sevenDay: snapshot.sevenDay, + historyWriteFailed: historyFailed ? true : nil + ) + let data = try JSONEncoder().encode(stored) + guard data.count <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + try data.write(to: cacheURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: cacheURL.path + ) + return true + } + } + + public static func deleteSnapshotIfIdle(at cacheURL: URL) throws { + try withCacheLock(at: cacheURL, waits: false) { + try AllowanceHistory.delete(in: historyDirectory(for: cacheURL)) + if FileManager.default.fileExists(atPath: cacheURL.path) { + try FileManager.default.removeItem(at: cacheURL) + } + } + } + + public static func historyDirectory(for cacheURL: URL) -> URL { + cacheURL.deletingLastPathComponent().appendingPathComponent("History", isDirectory: true) + } + + private static func withCacheLock( + at cacheURL: URL, + waits: Bool, + operation: () throws -> T + ) throws -> T { + // Keep this empty lock file: waiting relays may hold its inode after deletion. + let lockURL = cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw ClaudeRelayError.lockUnavailable } + defer { + flock(descriptor, LOCK_UN) + close(descriptor) + } + guard flock(descriptor, LOCK_EX | (waits ? 0 : LOCK_NB)) == 0 else { + throw ClaudeRelayError.lockUnavailable + } + return try operation() + } + + private static func enabledMarkerIdentity(at url: URL) -> NSObject? { + var uncachedURL = url + uncachedURL.removeAllCachedResourceValues() + let values = try? uncachedURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) + return values?.fileResourceIdentifier as? NSObject + } + + public static func readSnapshot(at cacheURL: URL, now: Date = Date()) throws + -> ClaudeAllowanceSnapshot { + let value = try JSONDecoder().decode( + ClaudeAllowanceSnapshot.self, + from: checkedCacheData(at: cacheURL) + ) + guard value.version == 1, + value.observedAt.timeIntervalSinceReferenceDate.isFinite, + value.observedAt <= now.addingTimeInterval(AllowanceHistory.maximumObservationClockSkew), + value.cliVersion == acceptedVersion(value.cliVersion), + value.fiveHour != nil || value.sevenDay != nil, + value.fiveHour.map(isValid) ?? true, + value.sevenDay.map(isValid) ?? true else { + throw ClaudeRelayError.invalidInput + } + return value + } + + private static func window(_ input: Input.Window?) throws + -> ClaudeAllowanceWindowSnapshot? { + guard let input else { return nil } + guard input.usedPercentage.isFinite, + (0 ... 100).contains(input.usedPercentage), + input.resetsAt.isFinite else { + throw ClaudeRelayError.invalidInput + } + let resetsAt = Date(timeIntervalSince1970: input.resetsAt) + guard resetsAt.timeIntervalSinceReferenceDate.isFinite else { + throw ClaudeRelayError.invalidInput + } + return ClaudeAllowanceWindowSnapshot( + remainingPercent: 100 - input.usedPercentage, + resetsAt: resetsAt + ) + } + + private static func acceptedVersion(_ version: String?) -> String? { + guard let version, + !version.isEmpty, + version.utf8.count <= 64, + version.unicodeScalars.allSatisfy({ + $0.value >= 0x20 && $0.value <= 0x7e + }) else { + return nil + } + return version + } + + private static func isValid( + _ window: ClaudeAllowanceWindowSnapshot + ) -> Bool { + window.remainingPercent.isFinite + && (0 ... 100).contains(window.remainingPercent) + && window.resetsAt.timeIntervalSinceReferenceDate.isFinite + } + + private static func checkedCacheData(at url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + let data = try Data(contentsOf: url) + guard data.count <= maximumCacheBytes else { + throw ClaudeRelayError.invalidInput + } + return data + } + + private struct Input: Decodable { + let version: String? + let rateLimits: RateLimits? + + enum CodingKeys: String, CodingKey { + case version + case rateLimits = "rate_limits" + } + + struct RateLimits: Decodable { + let fiveHour: Window? + let sevenDay: Window? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + } + } + + struct Window: Decodable { + let usedPercentage: Double + let resetsAt: Double + + enum CodingKeys: String, CodingKey { + case usedPercentage = "used_percentage" + case resetsAt = "resets_at" + } + } + } +} diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index db4302e..101438b 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -16,13 +16,13 @@ enum AnalyticsGraph: String, CaseIterable, Codable, Identifiable, Sendable { case usagePerToken = "Usage per token" case concurrency = "Concurrency" - var id: String { rawValue } - - static let coreCases: [AnalyticsGraph] = [ + static let coreCases: [Self] = [ .usageRemaining, - .tokenActivity + .tokenActivity, ] + var id: String { rawValue } + var usesAccountScope: Bool { self == .usageRemaining || self == .tokenActivity @@ -73,46 +73,6 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { } } -func accountTokenInterval( - at date: Date, - in intervals: [AccountTokenActivityInterval], - within range: DateInterval -) -> AccountTokenActivityInterval? { - intervals.first { - $0.start >= range.start - && $0.end <= range.end - && $0.start <= date - && date <= $0.end - } -} - -func steppedAccountTokenInterval( - in intervals: [AccountTokenActivityInterval], - from selected: AccountTokenActivityInterval?, - by offset: Int -) -> AccountTokenActivityInterval? { - let ordered = intervals.sorted { - $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start - } - guard !ordered.isEmpty else { return nil } - guard let selected, - let index = ordered.firstIndex(of: selected) else { - return offset < 0 ? ordered.last : ordered.first - } - return ordered[min(max(index + offset, 0), ordered.count - 1)] -} - -func retainedAccountTokenInterval( - _ selected: AccountTokenActivityInterval?, - in intervals: [AccountTokenActivityInterval], - range: DateInterval -) -> AccountTokenActivityInterval? { - guard let selected, - selected.start >= range.start, - selected.end <= range.end else { return nil } - return intervals.first { $0 == selected } -} - struct AccountTokenActivityDisplayInterval: Equatable, Hashable, Identifiable, Sendable { let sourceIntervals: [AccountTokenActivityInterval] @@ -240,7 +200,7 @@ struct AnalyticsExplorationState: Codable, Equatable, Sendable { ) var usesLocalAnalytics: Bool { - section == .graphs && graph.usesLocalAnalytics + section != .graphs || graph.usesLocalAnalytics } } @@ -255,39 +215,39 @@ final class AnalyticsWorkspaceStore: ObservableObject { [String: InsightDisposition] private let defaults: UserDefaults + private let keyPrefix: String - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, keyPrefix: String = "") { self.defaults = defaults - state = Self.restoredState(from: defaults) + self.keyPrefix = keyPrefix + state = Self.restoredState(from: defaults, keyPrefix: keyPrefix) insightDispositions = Self.restoredInsightDispositions( - from: defaults + from: defaults, keyPrefix: keyPrefix ) } static func restoredState( - from defaults: UserDefaults + from defaults: UserDefaults, keyPrefix: String = "" ) -> AnalyticsExplorationState { - guard let data = defaults.data(forKey: Self.persistenceKey), + guard let data = defaults.data(forKey: keyPrefix + Self.persistenceKey), let restored = try? JSONDecoder().decode( AnalyticsExplorationState.self, from: data ) else { return .initial } - guard AnalyticsGraph.coreCases.contains(restored.graph) else { - var core = restored - core.graph = .usageRemaining - core.filters = .all - return core + var state = restored + if !AnalyticsGraph.coreCases.contains(state.graph) { + state.graph = .usageRemaining } - return restored + return state } static func restoredInsightDispositions( - from defaults: UserDefaults + from defaults: UserDefaults, keyPrefix: String = "" ) -> [String: InsightDisposition] { guard let data = defaults.data( - forKey: Self.insightDispositionsPersistenceKey + forKey: keyPrefix + Self.insightDispositionsPersistenceKey ), let restored = try? JSONDecoder().decode( [String: InsightDisposition].self, from: data @@ -344,7 +304,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { if let data = try? JSONEncoder().encode(next) { defaults.set( data, - forKey: Self.insightDispositionsPersistenceKey + forKey: keyPrefix + Self.insightDispositionsPersistenceKey ) } } @@ -408,7 +368,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { guard next != state else { return } state = next if let data = try? JSONEncoder().encode(next) { - defaults.set(data, forKey: Self.persistenceKey) + defaults.set(data, forKey: keyPrefix + Self.persistenceKey) } } } @@ -480,7 +440,7 @@ enum UsageChartPointSource: Equatable, Sendable { case .derivedEstimate: UsageValueSource.derivedEstimate.rawValue case .accountHistory: UsageForecastReferenceSource.accountHistory.rawValue case .tokenEstimate: UsageForecastReferenceSource.tokenEstimate.rawValue - case .weeklyTarget: "Weekly target" + case .weeklyTarget: "Target" } } } @@ -500,48 +460,11 @@ struct UsageChartSelection: Equatable, Sendable { in chart: UsageChartSnapshot, within visibleRange: DateInterval? = nil ) -> UsageChartSelection? { - [ - nearestCandidate( - in: chart.allObserved, - series: .observed, - priority: 0, - source: .account, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.currentProjection, - series: .currentEstimate, - priority: 1, - source: .derivedEstimate, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.historicalProjection, - series: .pastEstimate, - priority: 2, - source: .accountHistory, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.estimatedBackfill, - series: .estimatedBackfill, - priority: 3, - source: .tokenEstimate, - to: date, - within: visibleRange - ), - nearestCandidate( - in: chart.target, - series: .target, - priority: 4, - source: .weeklyTarget, - to: date, - within: visibleRange - ) - ].compactMap { $0 }.min { + candidates(in: chart) + .filter { + visibleRange?.contains($0.point.date) ?? true + } + .min { let leftDistance = abs($0.point.date.timeIntervalSince(date)) let rightDistance = abs($1.point.date.timeIntervalSince(date)) if leftDistance == rightDistance { @@ -558,30 +481,6 @@ struct UsageChartSelection: Equatable, Sendable { } } - private static func nearestCandidate( - in points: [UsageChartPoint], - series: UsageChartSeries, - priority: Int, - source: UsageChartPointSource, - to date: Date, - within visibleRange: DateInterval? - ) -> Candidate? { - guard let point = nearestPoint( - in: points, - to: date, - date: \.date, - within: visibleRange - ) else { - return nil - } - return Candidate( - series: series, - point: point, - priority: priority, - source: source - ) - } - private static func candidates( in chart: UsageChartSnapshot ) -> [Candidate] { diff --git a/Sources/CodexLimits/ClaudeCodeIntegration.swift b/Sources/CodexLimits/ClaudeCodeIntegration.swift new file mode 100644 index 0000000..0544e09 --- /dev/null +++ b/Sources/CodexLimits/ClaudeCodeIntegration.swift @@ -0,0 +1,999 @@ +import ClaudeIntegrationCore +import Foundation + +enum ClaudeCodeReadiness: Equatable, Sendable { + case disabled + case checking + case notFound + case setUp + case waitingForData + case ready + case conflict + case updateRequired + case manualCleanupRequired + case failed +} + +struct ClaudeCodeInspection: Equatable, Sendable { + let readiness: ClaudeCodeReadiness + let snapshot: ClaudeAllowanceSnapshot? +} + +struct ClaudeCodeIntegrationPaths: Sendable { + let executableCandidates: [URL] + let settingsURL: URL + let dataDirectory: URL + let helperURL: URL + + var cacheURL: URL { dataDirectory.appendingPathComponent("snapshot.json") } + var enabledMarkerURL: URL { dataDirectory.appendingPathComponent("enabled") } + var installRecordURL: URL { dataDirectory.appendingPathComponent("install.json") } + var historyDirectory: URL { ClaudeRelay.historyDirectory(for: cacheURL) } + + static func live() -> Self { + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let applicationSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? fileManager.temporaryDirectory + let bundleID = Bundle.main.bundleIdentifier + ?? "com.github.thrr87.CodexLimits" + return Self( + executableCandidates: [ + URL(fileURLWithPath: "/opt/homebrew/bin/claude"), + URL(fileURLWithPath: "/usr/local/bin/claude"), + home.appendingPathComponent(".local/bin/claude") + ], + settingsURL: home.appendingPathComponent(".claude/settings.json"), + dataDirectory: applicationSupport + .appendingPathComponent(bundleID, isDirectory: true) + .appendingPathComponent("Integrations/ClaudeCode", isDirectory: true), + helperURL: Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers", isDirectory: true) + .appendingPathComponent("CodexLimitsClaudeRelay") + ) + } + + static func isolatedQA(base: URL, bundleURL: URL) -> Self { + let helperURL = bundleURL + .appendingPathComponent("Contents/Helpers", isDirectory: true) + .appendingPathComponent("CodexLimitsClaudeRelay") + return Self( + executableCandidates: [helperURL], + settingsURL: base + .appendingPathComponent("Fixtures/ClaudeCode", isDirectory: true) + .appendingPathComponent("settings.json"), + dataDirectory: base + .appendingPathComponent("Integrations/ClaudeCode", isDirectory: true), + helperURL: helperURL + ) + } +} + +actor ClaudeCodeSetupService { + enum SetupError: Error { + case notFound + case conflict + case updateRequired + case invalidSettings + } + + private struct StatusLineConfiguration: Codable, Equatable { + let type: String + let command: String + let padding: Int + } + + private struct InstallRecord: Codable { + let version: Int + let settingsPath: String + let configuration: StatusLineConfiguration + let createdSettingsFile: Bool? + } + + private enum StoredStatusLine { + case absent + case value(Data) + } + + private static let maximumSettingsBytes = 1_000_000 + private let paths: ClaudeCodeIntegrationPaths + private var selectedExecutableURL: URL? + private var configurationGeneration: UInt64 = 0 + + init( + paths: ClaudeCodeIntegrationPaths = .live(), + selectedExecutableURL: URL? = nil + ) { + self.paths = paths + self.selectedExecutableURL = selectedExecutableURL + } + + func inspect() -> ClaudeCodeInspection { + let snapshot = try? ClaudeRelay.readSnapshot(at: paths.cacheURL) + guard executableURL() != nil else { + return ClaudeCodeInspection(readiness: .notFound, snapshot: snapshot) + } + guard isExecutable(paths.helperURL) else { + return ClaudeCodeInspection( + readiness: .updateRequired, + snapshot: snapshot + ) + } + do { + let current = try storedStatusLine() + let owned = try ownedConfigurationData() + switch current { + case .absent: + return ClaudeCodeInspection( + readiness: .setUp, + snapshot: snapshot + ) + case let .value(data) where owned.contains(data): + let enabled = FileManager.default.fileExists( + atPath: paths.enabledMarkerURL.path + ) + return ClaudeCodeInspection( + readiness: enabled + ? (snapshot == nil ? .waitingForData : .ready) + : .setUp, + snapshot: snapshot + ) + case .value: + return ClaudeCodeInspection( + readiness: .conflict, + snapshot: snapshot + ) + } + } catch { + return ClaudeCodeInspection(readiness: .failed, snapshot: snapshot) + } + } + + func setUp() throws -> ClaudeCodeInspection { + guard executableURL() != nil else { throw SetupError.notFound } + guard isExecutable(paths.helperURL) else { + throw SetupError.updateRequired + } + let configuration = expectedConfiguration() + let expectedData = try canonicalData(configuration) + let settingsFileExisted = FileManager.default.fileExists( + atPath: paths.settingsURL.path + ) + let previousRecord = installRecord() + switch try storedStatusLine() { + case .absent: + break + case let .value(data): + guard try ownedConfigurationData().contains(data) + || data == expectedData else { + throw SetupError.conflict + } + } + + try FileManager.default.createDirectory( + at: paths.dataDirectory, + withIntermediateDirectories: true + ) + let record = InstallRecord( + version: 1, + settingsPath: paths.settingsURL.standardizedFileURL.path, + configuration: configuration, + createdSettingsFile: previousRecord?.configuration == configuration + && previousRecord?.createdSettingsFile == true + ? true + : !settingsFileExisted + ) + try writePrivate( + try JSONEncoder().encode(record), + to: paths.installRecordURL + ) + try writeStatusLine(configuration) + configurationGeneration &+= 1 + try writePrivate(Data(), to: paths.enabledMarkerURL) + return inspect() + } + + func selectExecutable(_ url: URL) -> ClaudeCodeInspection? { + guard isExecutable(url) else { return nil } + selectedExecutableURL = url.standardizedFileURL + return inspect() + } + + func deactivate() -> Bool { + guard stopWrites() else { return false } + do { + switch try storedStatusLine() { + case .absent: + try removeEmptyCreatedSettingsFile() + try? FileManager.default.removeItem(at: paths.installRecordURL) + return true + case let .value(data): + guard try ownedConfigurationData().contains(data) else { + return false + } + try removeStatusLine() + try? FileManager.default.removeItem(at: paths.installRecordURL) + return true + } + } catch { + return false + } + } + + func readSnapshot() -> ClaudeAllowanceSnapshot? { + try? ClaudeRelay.readSnapshot(at: paths.cacheURL) + } + + func readHistory() throws -> [AllowanceObservation] { + if let snapshot = readSnapshot() { + try AllowanceHistory.append(snapshot.historyObservations, in: paths.historyDirectory) + } + return try AllowanceHistory.read(in: paths.historyDirectory, now: Date()) + } + + func readOverview( + current: AllowanceObservation?, now: Date, safetyBuffer: Double + ) -> (snapshot: UsageOverviewSnapshot?, historyReadFailed: Bool) { + guard let since = UsageOverviewSnapshot.historyReadStart(current: current, now: now) else { + return (nil, false) + } + do { + try AllowanceHistory.append([current].compactMap { $0 }, in: paths.historyDirectory) + let observations = try AllowanceHistory.read(in: paths.historyDirectory, now: now, since: since) + return (UsageOverviewSnapshot( + observations: observations, current: current, now: now, safetyBuffer: safetyBuffer + ), false) + } catch { + return (UsageOverviewSnapshot( + observations: [], current: current, now: now, safetyBuffer: safetyBuffer + ), true) + } + } + + @discardableResult + func stopWrites() -> Bool { + configurationGeneration &+= 1 + do { + if FileManager.default.fileExists(atPath: paths.enabledMarkerURL.path) { + try FileManager.default.removeItem(at: paths.enabledMarkerURL) + } + return true + } catch { + return false + } + } + + func hasStoredData() -> Bool { + [ + paths.cacheURL, + paths.historyDirectory, + paths.enabledMarkerURL, + paths.installRecordURL + ].contains { + FileManager.default.fileExists(atPath: $0.path) + } + } + + func deleteData() async -> Bool { + let deactivated = deactivate() + let generation = configurationGeneration + guard !FileManager.default.fileExists(atPath: paths.enabledMarkerURL.path) else { + return false + } + if FileManager.default.fileExists(atPath: paths.dataDirectory.path) { + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while true { + guard generation == configurationGeneration, + !Task.isCancelled else { return false } + do { + try ClaudeRelay.deleteSnapshotIfIdle(at: paths.cacheURL) + break + } catch ClaudeRelayError.lockUnavailable { + guard ContinuousClock.now < deadline else { return false } + do { + try await Task.sleep(for: .milliseconds(20)) + } catch { + return false + } + } catch { + return false + } + } + } + var removed = true + for url in [ + paths.enabledMarkerURL, + paths.installRecordURL + ] where FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + removed = false + } + } + selectedExecutableURL = nil + return deactivated && removed + } + + private func executableURL() -> URL? { + ([selectedExecutableURL].compactMap { $0 } + + paths.executableCandidates).first(where: isExecutable) + } + + private func isExecutable(_ url: URL) -> Bool { + let resolved = url.resolvingSymlinksInPath() + let values = try? resolved.resourceValues( + forKeys: [.isRegularFileKey] + ) + return values?.isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) + } + + private func expectedConfiguration() -> StatusLineConfiguration { + StatusLineConfiguration( + type: "command", + command: [ + shellQuoted(paths.helperURL.path), + "--cache", + shellQuoted(paths.cacheURL.path), + "--enabled-marker", + shellQuoted(paths.enabledMarkerURL.path) + ].joined(separator: " "), + padding: 0 + ) + } + + private func ownedConfigurationData() throws -> Set { + var result: Set = [try canonicalData(expectedConfiguration())] + if let record = installRecord() { + result.insert(try canonicalData(record.configuration)) + } + return result + } + + private func installRecord() -> InstallRecord? { + guard let data = try? checkedData(at: paths.installRecordURL), + let record = try? JSONDecoder().decode( + InstallRecord.self, + from: data + ), record.version == 1, + record.settingsPath + == paths.settingsURL.standardizedFileURL.path else { + return nil + } + return record + } + + private func storedStatusLine() throws -> StoredStatusLine { + guard FileManager.default.fileExists(atPath: paths.settingsURL.path) else { + return .absent + } + let object = try settingsObject() + guard let value = object["statusLine"] else { return .absent } + guard JSONSerialization.isValidJSONObject(value) else { + throw SetupError.invalidSettings + } + return .value(try JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys] + )) + } + + private func writeStatusLine( + _ configuration: StatusLineConfiguration + ) throws { + var object = try settingsObject(ifMissing: [:]) + object["statusLine"] = try JSONSerialization.jsonObject( + with: canonicalData(configuration) + ) + try writeSettings(object) + } + + private func removeStatusLine() throws { + var object = try settingsObject() + object.removeValue(forKey: "statusLine") + if object.isEmpty, installRecord()?.createdSettingsFile == true { + try FileManager.default.removeItem(at: paths.settingsURL) + } else { + try writeSettings(object) + } + } + + private func removeEmptyCreatedSettingsFile() throws { + guard installRecord()?.createdSettingsFile == true, + FileManager.default.fileExists(atPath: paths.settingsURL.path), + try settingsObject().isEmpty else { + return + } + try FileManager.default.removeItem(at: paths.settingsURL) + } + + private func settingsObject( + ifMissing fallback: [String: Any]? = nil + ) throws -> [String: Any] { + guard FileManager.default.fileExists(atPath: paths.settingsURL.path) else { + if let fallback { return fallback } + throw SetupError.invalidSettings + } + let object = try JSONSerialization.jsonObject( + with: checkedData(at: paths.settingsURL) + ) + guard let dictionary = object as? [String: Any] else { + throw SetupError.invalidSettings + } + return dictionary + } + + private func writeSettings(_ object: [String: Any]) throws { + guard JSONSerialization.isValidJSONObject(object) else { + throw SetupError.invalidSettings + } + try FileManager.default.createDirectory( + at: paths.settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + guard data.count <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + try writePrivate(data, to: paths.settingsURL) + } + + private func canonicalData( + _ configuration: StatusLineConfiguration + ) throws -> Data { + let encoded = try JSONEncoder().encode(configuration) + let object = try JSONSerialization.jsonObject(with: encoded) + return try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) + } + + private func checkedData(at url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + let data = try Data(contentsOf: url) + guard data.count <= Self.maximumSettingsBytes else { + throw SetupError.invalidSettings + } + return data + } + + private func writePrivate(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } + + private func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +@MainActor +final class ClaudeCodeIntegrationStore: ObservableObject { + private struct DemandRequest: Equatable { + let lifecycle: UInt64 + let demand: UInt64 + } + + @Published private(set) var readiness: ClaudeCodeReadiness + @Published private(set) var snapshot: ClaudeAllowanceSnapshot? + @Published private(set) var history: [AllowanceObservation] = [] + @Published private(set) var overview: UsageOverviewSnapshot? + @Published private(set) var historyIssue: String? + @Published private(set) var hasStoredData = false + @Published private(set) var displayNow = Date() + + var displayFreshness: ClaudeAllowanceSnapshot.DisplayFreshness? { + guard let snapshot else { return nil } + let freshness = snapshot.displayFreshness(now: displayNow) + guard freshness == .fresh else { return freshness } + switch readiness { + case .notFound, .setUp, .conflict, .updateRequired, + .manualCleanupRequired, .failed: + return .stale + default: + return freshness + } + } + + private let service: ClaudeCodeSetupService + private let integrationWorkCoordinator: IntegrationWorkCoordinator + private var enabled: Bool + private var menuBarSourceActive: Bool + private var visible = false + private var historyVisible = false + private var overviewSafetyBuffer: Double = 3 + private var lifecycleGeneration: UInt64 = 0 + private var demandGeneration: UInt64 = 0 + private var notificationObserver: NSObjectProtocol? + private var boundaryTask: Task? + private var readinessRefreshRequest: DemandRequest? + private var snapshotReadRequest: DemandRequest? + private var snapshotReadPending = false + + init( + isEnabled: Bool, + menuBarSourceActive: Bool = true, + service: ClaudeCodeSetupService = ClaudeCodeSetupService(), + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator() + ) { + enabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive + self.service = service + self.integrationWorkCoordinator = integrationWorkCoordinator + readiness = isEnabled ? .checking : .disabled + if isEnabled { + observeSnapshots() + if menuBarSourceActive { + Task { [weak self] in + await self?.refreshReadiness(priority: .automatic) + } + } + } + } + + func settingsPresented() async { + let generation = lifecycleGeneration + let expectedEnabled = enabled + if expectedEnabled { + await refreshReadiness(priority: .settings) + return + } + await integrationWorkCoordinator.run(priority: .settings) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: expectedEnabled) else { + return + } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { + return + } + self.hasStoredData = hasStoredData + if hasStoredData { + let snapshot = await self.service.readSnapshot() + guard self.isCurrent(generation, enabled: false) else { + return + } + self.setSnapshot(snapshot) + } + } + } + + func setEnabled(_ enabled: Bool) async { + guard enabled != self.enabled else { return } + self.enabled = enabled + lifecycleGeneration &+= 1 + let generation = lifecycleGeneration + if enabled { + readiness = .checking + observeSnapshots() + await refreshReadiness(priority: .explicit) + } else { + history = [] + overview = nil + historyVisible = false + snapshotReadPending = false + stopObservingSnapshots() + boundaryTask?.cancel() + boundaryTask = nil + await service.stopWrites() + guard isCurrent(generation, enabled: false) else { return } + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: false) else { + return + } + let removed = await self.service.deactivate() + guard self.isCurrent(generation, enabled: false) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { return } + self.hasStoredData = hasStoredData + self.readiness = removed + ? .disabled + : .manualCleanupRequired + } + } + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard active != menuBarSourceActive else { return } + menuBarSourceActive = active + demandGeneration &+= 1 + scheduleNextBoundary() + if active { + await refreshReadiness(priority: .automatic) + } + } + + func setVisible(_ visible: Bool, includeHistory: Bool = true, safetyBuffer: Double = 3) async { + guard !Task.isCancelled else { return } + let historyVisible = visible && includeHistory + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + guard visible != self.visible || historyVisible != self.historyVisible + || buffer != overviewSafetyBuffer else { return } + self.visible = visible + self.historyVisible = historyVisible + overviewSafetyBuffer = buffer + overview = nil + if !historyVisible { history = [] } + demandGeneration &+= 1 + scheduleNextBoundary() + if visible { + await refreshReadiness(priority: .visible) + } + } + + func setUp() async { + guard enabled else { return } + let generation = lifecycleGeneration + readiness = .checking + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true) else { + return + } + do { + let inspection = try await self.service.setUp() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = true + await self.refreshHistory(for: DemandRequest( + lifecycle: generation, demand: self.demandGeneration + )) + } catch ClaudeCodeSetupService.SetupError.notFound { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .notFound + } catch ClaudeCodeSetupService.SetupError.conflict { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .conflict + } catch ClaudeCodeSetupService.SetupError.updateRequired { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .updateRequired + } catch { + guard self.isCurrent(generation, enabled: true) else { return } + self.readiness = .failed + } + } + } + + func selectExecutable(_ url: URL) async -> Bool { + guard enabled else { return false } + let generation = lifecycleGeneration + readiness = .checking + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true) else { + return + } + guard let inspection = await self.service.selectExecutable(url) else { + self.readiness = .notFound + return + } + guard self.isCurrent(generation, enabled: true) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = hasStoredData + await self.refreshHistory(for: DemandRequest( + lifecycle: generation, demand: self.demandGeneration + )) + } + return isCurrent(generation, enabled: true) + && readiness != .checking + && readiness != .notFound + } + + func checkForNewObservation( + priority: IntegrationWorkPriority = .explicit + ) async { + guard enabled else { return } + let generation = lifecycleGeneration + let request = DemandRequest( + lifecycle: generation, + demand: demandGeneration + ) + guard demandIsCurrent(request, priority: priority) else { return } + if snapshotReadRequest == request { + if priority == .automatic { snapshotReadPending = true } + return + } + snapshotReadRequest = request + defer { + if snapshotReadRequest == request { + snapshotReadRequest = nil + if snapshotReadPending { + snapshotReadPending = false + Task { @MainActor [weak self] in + await self?.readAutomaticSnapshotIfDemanded() + } + } + } + } + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let snapshot = await self.service.readSnapshot() + guard self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + self.setSnapshot(snapshot) + if snapshot != nil { + switch self.readiness { + case .ready, .waitingForData, .failed, .checking: + self.readiness = .ready + default: + break + } + } else if self.snapshot != nil { + self.readiness = .failed + } else if self.readiness == .ready { + self.readiness = .waitingForData + } + await self.refreshHistory(for: request) + } + } + + func deleteData() async { + enabled = false + menuBarSourceActive = false + visible = false + historyVisible = false + overview = nil + snapshotReadPending = false + lifecycleGeneration &+= 1 + let generation = lifecycleGeneration + stopObservingSnapshots() + boundaryTask?.cancel() + boundaryTask = nil + await service.stopWrites() + guard isCurrent(generation, enabled: false) else { return } + await integrationWorkCoordinator.run(priority: .explicit) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: false) else { + return + } + let removed = await self.service.deleteData() + guard self.isCurrent(generation, enabled: false) else { return } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: false) else { return } + self.snapshot = nil + self.history = [] + self.overview = nil + self.historyIssue = nil + self.hasStoredData = hasStoredData + self.displayNow = Date() + self.readiness = removed + ? .disabled + : .manualCleanupRequired + } + } + + private func refreshReadiness( + priority: IntegrationWorkPriority + ) async { + guard enabled else { return } + let generation = lifecycleGeneration + let request = DemandRequest( + lifecycle: generation, + demand: demandGeneration + ) + guard readinessRefreshRequest != request, + demandIsCurrent(request, priority: priority) else { return } + readinessRefreshRequest = request + defer { + if readinessRefreshRequest == request { + readinessRefreshRequest = nil + } + } + readiness = .checking + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, + self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let inspection = await self.service.inspect() + guard self.isCurrent(generation, enabled: true), + self.demandIsCurrent(request, priority: priority) else { + return + } + let hasStoredData = await self.service.hasStoredData() + guard self.isCurrent(generation, enabled: true) else { return } + self.apply(inspection) + self.hasStoredData = hasStoredData + await self.refreshHistory(for: request) + } + } + + private func refreshHistory(for request: DemandRequest) async { + guard !Task.isCancelled, visible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + if !historyVisible { + let result = await service.readOverview( + current: snapshot?.historyObservations.first { $0.metric == "claude-seven-day" }, + now: displayNow, safetyBuffer: overviewSafetyBuffer + ) + guard !Task.isCancelled, visible, !historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + overview = result.snapshot + historyIssue = result.historyReadFailed ? "Claude Code usage history couldn’t be read." + : snapshot?.historyWriteFailed == true ? "Some Claude Code usage history couldn’t be saved." : nil + return + } + do { + let history = try await service.readHistory() + guard historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + self.history = history + historyIssue = snapshot?.historyWriteFailed == true + ? "Some Claude Code usage history couldn’t be saved." + : nil + } catch { + guard historyVisible, isCurrent(request.lifecycle, enabled: true), + request.demand == demandGeneration else { return } + historyIssue = "Claude Code usage history couldn’t be read." + } + } + + private func isCurrent( + _ generation: UInt64, + enabled: Bool + ) -> Bool { + lifecycleGeneration == generation && self.enabled == enabled + } + + private func demandIsCurrent( + _ request: DemandRequest, + priority: IntegrationWorkPriority + ) -> Bool { + guard request.lifecycle == lifecycleGeneration else { return false } + switch priority { + case .visible: + return request.demand == demandGeneration && visible + case .automatic: + return request.demand == demandGeneration + && (menuBarSourceActive || visible) + case .explicit, .settings: + return true + } + } + + private func apply(_ inspection: ClaudeCodeInspection) { + readiness = inspection.snapshot == nil && snapshot != nil + && inspection.readiness == .waitingForData + ? .failed + : inspection.readiness + setSnapshot(inspection.snapshot) + if inspection.snapshot != nil { + hasStoredData = true + } + } + + private func observeSnapshots() { + guard notificationObserver == nil else { return } + notificationObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name( + ClaudeRelay.snapshotChangedNotificationName + ), + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.readAutomaticSnapshotIfDemanded() + } + } + } + + private func readAutomaticSnapshotIfDemanded() async { + guard menuBarSourceActive || visible else { return } + await checkForNewObservation(priority: .automatic) + } + + private func stopObservingSnapshots() { + guard let notificationObserver else { return } + DistributedNotificationCenter.default().removeObserver( + notificationObserver + ) + self.notificationObserver = nil + } + + private func setSnapshot(_ snapshot: ClaudeAllowanceSnapshot?) { + if let snapshot { + self.snapshot = snapshot + hasStoredData = true + if snapshot.historyWriteFailed == true { + historyIssue = "Some Claude Code usage history couldn’t be saved." + } + } + displayNow = Date() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleNextBoundary() + } + + private func scheduleNextBoundary() { + boundaryTask?.cancel() + guard enabled, menuBarSourceActive || visible, let snapshot else { + boundaryTask = nil + return + } + let now = Date() + let candidates = [ + snapshot.observedAt.addingTimeInterval(30 * 60), + snapshot.fiveHour?.resetsAt, + snapshot.sevenDay?.resetsAt + ].compactMap { $0 }.filter { $0 > now }.sorted() + guard let boundary = candidates.first else { + boundaryTask = nil + return + } + boundaryTask = Task { [weak self] in + do { + try await Task.sleep( + for: .seconds(boundary.timeIntervalSinceNow) + ) + } catch { + return + } + guard let self else { return } + displayNow = Date() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleNextBoundary() + } + } + + deinit { + boundaryTask?.cancel() + if let notificationObserver { + DistributedNotificationCenter.default().removeObserver( + notificationObserver + ) + } + } +} + +extension ClaudeAllowanceSnapshot { + enum DisplayFreshness: Equatable { + case fresh + case stale + case expired + } + + func displayFreshness(now: Date) -> DisplayFreshness { + if let primaryReset = sevenDay?.resetsAt ?? fiveHour?.resetsAt, + primaryReset <= now { + return .expired + } + return (-AllowanceHistory.maximumObservationClockSkew ... 30 * 60).contains(now.timeIntervalSince(observedAt)) + ? .fresh + : .stale + } + + func sevenDayMenuBarText(now: Date) -> String { + guard let sevenDay, sevenDay.resetsAt > now else { return "—" } + return "\(Int(sevenDay.remainingPercent.rounded()))%" + } +} diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift index c54357d..c56a214 100644 --- a/Sources/CodexLimits/CodexAssistedInsights.swift +++ b/Sources/CodexLimits/CodexAssistedInsights.swift @@ -217,26 +217,6 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { let activeTimeAvailable: ActiveTimeAvailable let scope: Scope - var fingerprint: String { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(self), - var object = try? JSONSerialization.jsonObject( - with: data - ) as? [String: Any] else { - return "" - } - object.removeValue(forKey: "generatedAt") - guard let stableData = try? JSONSerialization.data( - withJSONObject: object, - options: [.sortedKeys] - ) else { - return "" - } - return SHA256.hash(data: stableData) - .map { String(format: "%02x", $0) } - .joined() - } - static func make( reader: UsageReaderSnapshot, exploration: AnalyticsExplorationState, @@ -1318,23 +1298,9 @@ final class CodexAssistedInsightStore: ObservableObject { } } - var showsCard: Bool { - showsAnalyzeAction - || isRunning - || overhead != nil - || !persistedResults.isEmpty - } - - func showsCard(for scope: CodexAssistedAnalysisScope) -> Bool { - showsAnalyzeAction - || isRunning - || overhead != nil - || result(for: scope) != nil - } - func showsCard( for scope: CodexAssistedAnalysisScope, - sourceSelection: CodexSourceSelection? + sourceSelection: CodexSourceSelection? = nil ) -> Bool { showsAnalyzeAction || isRunning @@ -1345,20 +1311,9 @@ final class CodexAssistedInsightStore: ObservableObject { ) != nil } - func result( - for scope: CodexAssistedAnalysisScope - ) -> CodexAssistedAnalysisResult? { - if resultScope?.fingerprint == scope.fingerprint, let result { - return result - } - return persistedResults.last { - $0.scopeFingerprint == scope.fingerprint - }?.result - } - func result( for scope: CodexAssistedAnalysisScope, - sourceSelection: CodexSourceSelection? + sourceSelection: CodexSourceSelection? = nil ) -> CodexAssistedAnalysisResult? { let selectionFingerprint = sourceSelection?.fingerprint var candidates: [CodexAssistedAnalysisResult] = [] diff --git a/Sources/CodexLimits/CodexClient.swift b/Sources/CodexLimits/CodexClient.swift index aaad5d6..7429c93 100644 --- a/Sources/CodexLimits/CodexClient.swift +++ b/Sources/CodexLimits/CodexClient.swift @@ -69,7 +69,7 @@ enum CodexClientError: LocalizedError { } final class CodexAppServerConnection: @unchecked Sendable { - private static let defaultMaximumLineBytes = 16 * 1_024 * 1_024 + private static let defaultMaximumLineBytes = 8 * 1_024 * 1_024 let input: FileHandle let isRunning: () -> Bool @@ -264,6 +264,19 @@ private final class CodexAppServerProcessOwner: @unchecked Sendable { } } +private final class CodexExecutableSelection: @unchecked Sendable { + private let lock = NSLock() + private var url: URL? + + func get() -> URL? { + lock.withLock { url } + } + + func set(_ url: URL?) { + lock.withLock { self.url = url } + } +} + actor CodexClient { static let shared = CodexClient( makeConnection: CodexClient.liveConnection, @@ -272,14 +285,20 @@ actor CodexClient { private static let weeklyWindowDurationMinutes = 10_080 private static let executablePaths = [ - "/opt/homebrew/bin/codex", - "/usr/local/bin/codex" + URL(fileURLWithPath: "/opt/homebrew/bin/codex"), + URL(fileURLWithPath: "/usr/local/bin/codex"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/bin/codex") ] + private static let executableSelection = CodexExecutableSelection() private let makeConnection: () throws -> CodexAppServerConnection private let executableIdentity: () -> String? private let protocolGate = CodexProtocolGate() private let timeoutNanoseconds: UInt64 + private let connectionIdleNanoseconds: UInt64 private var connection: CodexAppServerConnection? + private var connectionIdleTask: Task? + private var connectionIdleGeneration: UInt64 = 0 private var initialized = false private var serverCLIVersion: String? private var connectionExecutableIdentity: String? @@ -308,17 +327,35 @@ actor CodexClient { init( makeConnection: @escaping () throws -> CodexAppServerConnection = CodexClient.liveConnection, executableIdentity: @escaping () -> String? = { nil }, - timeout: TimeInterval = 15 + timeout: TimeInterval = 10, + connectionIdleTimeout: TimeInterval = 5 ) { self.makeConnection = makeConnection self.executableIdentity = executableIdentity timeoutNanoseconds = UInt64(max(timeout, 0.001) * 1_000_000_000) + connectionIdleNanoseconds = UInt64( + max(connectionIdleTimeout, 0.001) * 1_000_000_000 + ) } static func fetch() async throws -> CodexFetchResult { try await shared.fetch(fetchedAt: Date()) } + static func cancelFetch() async { + await shared.cancelInFlightFetch() + } + + static func selectExecutable(_ url: URL?) -> Bool { + guard let url else { + executableSelection.set(nil) + return true + } + guard isExecutable(url) else { return false } + executableSelection.set(url.standardizedFileURL) + return true + } + func fetch(fetchedAt: Date) async throws -> CodexFetchResult { if let inFlightFetch { return try await inFlightFetch.value @@ -333,6 +370,11 @@ actor CodexClient { return try await task.value } + private func cancelInFlightFetch() { + inFlightFetch?.cancel() + invalidateConnection() + } + func threadProjectionResponse( for request: ThreadProjectionReadRequest ) async throws -> Data { @@ -353,17 +395,45 @@ actor CodexClient { _ operation: () async throws -> T ) async throws -> T { await protocolGate.enter() + cancelScheduledConnectionRelease() do { try Task.checkCancellation() let result = try await operation() + scheduleConnectionRelease() await protocolGate.leave() return result } catch { + scheduleConnectionRelease() await protocolGate.leave() throw error } } + private func cancelScheduledConnectionRelease() { + connectionIdleGeneration &+= 1 + connectionIdleTask?.cancel() + connectionIdleTask = nil + } + + private func scheduleConnectionRelease() { + cancelScheduledConnectionRelease() + let generation = connectionIdleGeneration + connectionIdleTask = Task { [weak self, connectionIdleNanoseconds] in + do { + try await Task.sleep(nanoseconds: connectionIdleNanoseconds) + } catch { + return + } + await self?.releaseConnectionIfIdle(generation: generation) + } + } + + private func releaseConnectionIfIdle(generation: UInt64) { + guard generation == connectionIdleGeneration else { return } + connectionIdleTask = nil + invalidateConnection() + } + private func fetchWithReconnect( fetchedAt: Date ) async throws -> CodexFetchResult { @@ -628,12 +698,18 @@ actor CodexClient { } private static func liveExecutableURL() -> URL? { - executablePaths.lazy.compactMap { path -> URL? in - guard FileManager.default.isExecutableFile(atPath: path) else { - return nil - } - return URL(fileURLWithPath: path).resolvingSymlinksInPath() - }.first + let selected = executableSelection.get().map { [$0] } ?? [] + return (selected + executablePaths).first(where: isExecutable)? + .resolvingSymlinksInPath() + } + + private static func isExecutable(_ url: URL) -> Bool { + let resolved = url.resolvingSymlinksInPath() + let values = try? resolved.resourceValues( + forKeys: [.isRegularFileKey] + ) + return values?.isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) } private static func liveExecutableIdentity() -> String? { @@ -731,6 +807,7 @@ actor CodexClient { } deinit { + connectionIdleTask?.cancel() connection?.stop() } diff --git a/Sources/CodexLimits/CodexLimitsApp.swift b/Sources/CodexLimits/CodexLimitsApp.swift index 33e5f60..3f1ae40 100644 --- a/Sources/CodexLimits/CodexLimitsApp.swift +++ b/Sources/CodexLimits/CodexLimitsApp.swift @@ -3,6 +3,9 @@ import SwiftUI @main struct CodexLimitsApp: App { @StateObject private var monitor: UsageMonitor + @StateObject private var integrations: IntegrationPreferences + @StateObject private var claudeCode: ClaudeCodeIntegrationStore + @StateObject private var grok: GrokIntegrationStore #if CODEX_LIMITS_QA @StateObject private var assistedInsights: CodexAssistedInsightStore #endif @@ -10,20 +13,48 @@ struct CodexLimitsApp: App { init() { #if CODEX_LIMITS_QA + let bundleID = Bundle.main.bundleIdentifier ?? "com.github.thrr87.CodexLimits.QA" let base = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first? .appendingPathComponent( - "com.github.thrr87.CodexLimits.QA", + bundleID, isDirectory: true ) ?? FileManager.default.temporaryDirectory.appendingPathComponent( - "com.github.thrr87.CodexLimits.QA", + bundleID, isDirectory: true ) let defaults = UserDefaults( - suiteName: "com.github.thrr87.CodexLimits.QA.defaults" + suiteName: bundleID + ".defaults" ) ?? .standard + let integrations = IntegrationPreferences(defaults: defaults) + _ = CodexClient.selectExecutable(integrations.codexExecutableURL) + let integrationWorkCoordinator = IntegrationWorkCoordinator() + let claudePaths = ClaudeCodeIntegrationPaths.isolatedQA( + base: base, + bundleURL: Bundle.main.bundleURL + ) + _integrations = StateObject(wrappedValue: integrations) + _grok = StateObject(wrappedValue: GrokIntegrationStore( + isEnabled: integrations.isEnabled(.grok), + menuBarSourceActive: integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining, + selectedExecutableURL: integrations.grokExecutableURL, + cacheURL: base.appendingPathComponent("Integrations/Grok/snapshot.json"), + integrationWorkCoordinator: integrationWorkCoordinator + )) + _claudeCode = StateObject( + wrappedValue: ClaudeCodeIntegrationStore( + isEnabled: integrations.isEnabled(.claudeCode), + menuBarSourceActive: integrations.menuBarMetric + == .claudeSevenDayUsageRemaining, + service: ClaudeCodeSetupService( + paths: claudePaths, + selectedExecutableURL: integrations.claudeExecutableURL + ), + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) analyticsDefaults = defaults let collector = LocalActivityCollector( stateDirectory: base.appendingPathComponent( @@ -46,7 +77,11 @@ struct CodexLimitsApp: App { "History", isDirectory: true ), - localActivityCollector: collector + isEnabled: integrations.isEnabled(.codex), + menuBarSourceActive: integrations.menuBarMetric + == .codexWeeklyUsageRemaining, + localActivityCollector: collector, + integrationWorkCoordinator: integrationWorkCoordinator ) ) _assistedInsights = StateObject( @@ -58,7 +93,35 @@ struct CodexLimitsApp: App { #else LoginItem.enableByDefault() analyticsDefaults = .standard - _monitor = StateObject(wrappedValue: UsageMonitor()) + let integrations = IntegrationPreferences() + _ = CodexClient.selectExecutable(integrations.codexExecutableURL) + let integrationWorkCoordinator = IntegrationWorkCoordinator() + _integrations = StateObject(wrappedValue: integrations) + _grok = StateObject(wrappedValue: GrokIntegrationStore( + isEnabled: integrations.isEnabled(.grok), + menuBarSourceActive: integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining, + selectedExecutableURL: integrations.grokExecutableURL, + integrationWorkCoordinator: integrationWorkCoordinator + )) + _claudeCode = StateObject( + wrappedValue: ClaudeCodeIntegrationStore( + isEnabled: integrations.isEnabled(.claudeCode), + menuBarSourceActive: integrations.menuBarMetric + == .claudeSevenDayUsageRemaining, + service: ClaudeCodeSetupService( + selectedExecutableURL: integrations.claudeExecutableURL + ), + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) + _monitor = StateObject( + wrappedValue: UsageMonitor( + isEnabled: integrations.isEnabled(.codex), + menuBarSourceActive: integrations.menuBarMetric + == .codexWeeklyUsageRemaining, + integrationWorkCoordinator: integrationWorkCoordinator + ) + ) #endif } @@ -68,6 +131,9 @@ struct CodexLimitsApp: App { Window("Codex Limits QA", id: "qa-window") { MenuContentView( monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok, defaults: analyticsDefaults, assistedInsights: assistedInsights ) @@ -77,20 +143,101 @@ struct CodexLimitsApp: App { MenuBarExtra { MenuContentView( monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok, defaults: analyticsDefaults ) } label: { HStack(spacing: 4) { Image(systemName: "gauge.with.dots.needle.50percent") - Text(monitor.readerSnapshot.menuBarText) - .monospacedDigit() + if integrations.menuBarMetric != .none { + Text(menuBarText) + .monospacedDigit() + if selectedMenuMetricIsStale { + Image(systemName: "clock.badge.exclamationmark") + .accessibilityHidden(true) + } + } } + .accessibilityLabel(menuBarAccessibilityLabel) } .menuBarExtraStyle(.window) #endif Settings { - SettingsView(monitor: monitor) + SettingsView( + monitor: monitor, + integrations: integrations, + claudeCode: claudeCode, + grok: grok + ) + .defaultAppStorage(analyticsDefaults) + } + } + + private var menuBarText: String { + switch integrations.menuBarMetric { + case .none: + "" + case .codexWeeklyUsageRemaining: + monitor.readerSnapshot.menuBarText + case .claudeSevenDayUsageRemaining: + claudeCode.snapshot?.sevenDayMenuBarText( + now: claudeCode.displayNow + ) ?? "—" + case .grokCurrentPeriodUsageRemaining: + grok.menuBarText + } + } + + private var menuBarAccessibilityLabel: String { + switch integrations.menuBarMetric { + case .none: + return "Codex Limits" + case .codexWeeklyUsageRemaining: + let value = monitor.readerSnapshot.weeklyUsageRemaining.map { + $0.window.remainingPercent.formatted( + .number.precision(.fractionLength(0 ... 2)) + ) + " percent remaining" + } ?? "unavailable" + return "\(integrations.menuBarMetric.displayName), \(value), \(monitor.readerSnapshot.freshness.rawValue)" + case .claudeSevenDayUsageRemaining: + let freshness = claudeCode.displayFreshness.map { + switch $0 { + case .fresh: "fresh" + case .stale: "stale" + case .expired: "new usage observation needed" + } + } ?? "unavailable" + let value = claudeCode.snapshot?.sevenDay.flatMap { + $0.resetsAt > claudeCode.displayNow ? $0 : nil + }.map { + $0.remainingPercent.formatted( + .number.precision(.fractionLength(0 ... 2)) + ) + " percent remaining" + } ?? "unavailable" + return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)" + case .grokCurrentPeriodUsageRemaining: + let value = grok.currentSnapshot.map { + $0.remainingPercent.formatted(.number.precision(.fractionLength(0 ... 2))) + + " percent remaining, \($0.period.rawValue)" + } ?? "unavailable" + let freshness = grok.currentSnapshot == nil ? "unavailable" : (grok.isStale ? "stale" : "fresh") + return "\(integrations.menuBarMetric.displayName), \(value), \(freshness)" + } + } + + private var selectedMenuMetricIsStale: Bool { + switch integrations.menuBarMetric { + case .codexWeeklyUsageRemaining: + return monitor.readerSnapshot.freshness == .stale + case .claudeSevenDayUsageRemaining: + return claudeCode.displayFreshness == .stale + case .grokCurrentPeriodUsageRemaining: + return grok.isStale + case .none: + return false } } } diff --git a/Sources/CodexLimits/GrokBillingClient.swift b/Sources/CodexLimits/GrokBillingClient.swift new file mode 100644 index 0000000..2888c0d --- /dev/null +++ b/Sources/CodexLimits/GrokBillingClient.swift @@ -0,0 +1,427 @@ +import Darwin +import Foundation + +enum GrokBillingError: Error, LocalizedError, Equatable { + case notFound, authenticationRequired, unsupported, missingAllowance + case unknownPeriod, invalidReset, invalidResponse, timedOut, connectionLost, failed + + var errorDescription: String? { + switch self { + case .notFound: "Grok Build could not be found." + case .authenticationRequired: "Sign in with grok login to read your usage." + case .unsupported: "This version of Grok Build does not support usage reads." + case .missingAllowance: "Grok has not provided an allowance for this account." + case .unknownPeriod: "Grok returned an unsupported usage period." + case .invalidReset: "Grok did not provide a valid reset date." + case .invalidResponse: "Grok returned usage data that could not be read." + case .timedOut: "Grok took too long to respond." + case .connectionLost: "The connection to Grok ended before usage was received." + case .failed: "Grok usage could not be read. Try again later." + } + } +} + +struct GrokAllowanceSnapshot: Codable, Equatable, Sendable { + enum Period: String, Codable, Sendable { case weekly, monthly } + + let reportedUsedPercent: Double + let period: Period + let resetsAt: Date + let observedAt: Date + let sourceVersion: String? + let subscriptionTier: String? + let prepaidBalanceUSD: Double? + let onDemandUsedUSD: Double? + let onDemandCapUSD: Double? + let isUnifiedBilling: Bool? + let measurementSource: String + let startsAt: Date? + + init( + reportedUsedPercent: Double, + period: Period, + resetsAt: Date, + observedAt: Date, + sourceVersion: String?, + subscriptionTier: String?, + prepaidBalanceUSD: Double?, + onDemandUsedUSD: Double?, + onDemandCapUSD: Double?, + isUnifiedBilling: Bool?, + measurementSource: String, + startsAt: Date? = nil + ) { + self.reportedUsedPercent = reportedUsedPercent + self.period = period + self.resetsAt = resetsAt + self.observedAt = observedAt + self.sourceVersion = sourceVersion + self.subscriptionTier = subscriptionTier + self.prepaidBalanceUSD = prepaidBalanceUSD + self.onDemandUsedUSD = onDemandUsedUSD + self.onDemandCapUSD = onDemandCapUSD + self.isUnifiedBilling = isUnifiedBilling + self.measurementSource = measurementSource + self.startsAt = startsAt + } + + var remainingPercent: Double { + 100 - min(100, max(0, reportedUsedPercent)) + } + + var isValid: Bool { + reportedUsedPercent.isFinite + && Self.isSupportedDate(observedAt) + && Self.isSupportedDate(resetsAt) + && resetsAt.timeIntervalSince1970 > 0 + && (startsAt.map { Self.isSupportedDate($0) && $0 < resetsAt } ?? true) + && ["creditUsagePercent", "legacyCredits"].contains(measurementSource) + && [prepaidBalanceUSD, onDemandUsedUSD, onDemandCapUSD] + .allSatisfy { $0.map { $0.isFinite && $0 >= 0 } ?? true } + && sourceVersion == Self.safeText(sourceVersion, limit: 64) + && subscriptionTier == Self.safeText(subscriptionTier, limit: 80) + } + + static func decode( + _ data: Data, + observedAt: Date, + sourceVersion: String? + ) throws -> Self { + guard data.count <= 1_048_576, + isSupportedDate(observedAt), + let result = try? JSONSerialization.jsonObject(with: data) + as? [String: Any] else { + throw GrokBillingError.invalidResponse + } + guard let config = result["config"] as? [String: Any] else { + throw GrokBillingError.missingAllowance + } + let used: Double + let period: Period + let reset: Date + let start: Date? + let source: String + if config.keys.contains("creditUsagePercent") + || config.keys.contains("currentPeriod") { + guard let value = number(config["creditUsagePercent"]) else { + throw GrokBillingError.missingAllowance + } + used = value + guard let current = config["currentPeriod"] as? [String: Any] else { + throw GrokBillingError.unknownPeriod + } + switch current["type"] as? String { + case "USAGE_PERIOD_TYPE_WEEKLY": period = .weekly + case "USAGE_PERIOD_TYPE_MONTHLY": period = .monthly + default: throw GrokBillingError.unknownPeriod + } + (start, reset) = try periodDates(end: current["end"], start: current["start"]) + source = "creditUsagePercent" + } else { + guard let limit = cents(config["monthlyLimit"]), limit > 0, + let value = cents(config["used"]) else { + throw GrokBillingError.missingAllowance + } + used = value / limit * 100 + guard used.isFinite else { throw GrokBillingError.invalidResponse } + period = .monthly + (start, reset) = try periodDates( + end: config["billingPeriodEnd"], start: config["billingPeriodStart"] + ) + source = "legacyCredits" + } + let unified = config["isUnifiedBillingUser"] as? NSNumber + return Self( + reportedUsedPercent: used, + period: period, + resetsAt: reset, + observedAt: observedAt, + sourceVersion: safeText(sourceVersion, limit: 64), + subscriptionTier: safeText(result["subscription_tier"] as? String, limit: 80), + prepaidBalanceUSD: cents(config["prepaidBalance"]).map { $0 / 100 }, + onDemandUsedUSD: cents(config["onDemandUsed"]).map { $0 / 100 }, + onDemandCapUSD: cents(config["onDemandCap"]).map { $0 / 100 }, + isUnifiedBilling: unified.flatMap { + CFGetTypeID($0) == CFBooleanGetTypeID() ? $0.boolValue : nil + }, + measurementSource: source, + startsAt: start + ) + } + + private static func number(_ value: Any?) -> Double? { + guard let value = value as? NSNumber, + CFGetTypeID(value) != CFBooleanGetTypeID(), + value.doubleValue.isFinite else { return nil } + return value.doubleValue + } + + private static func cents(_ value: Any?) -> Double? { + guard let object = value as? [String: Any] else { return nil } + // The provider's protobuf JSON omits val for a present zero-valued Cent. + guard !object.isEmpty else { return 0 } + guard let value = number(object["val"]), value >= 0 else { return nil } + return value + } + + private static func periodDates(end: Any?, start: Any?) throws -> (Date?, Date) { + let parser = ISO8601DateFormatter() + func date(_ value: Any?) -> Date? { + guard let value = value as? String, value.utf8.count <= 64 else { return nil } + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = parser.date(from: value) { return date } + parser.formatOptions = [.withInternetDateTime] + return parser.date(from: value) + } + guard let end = date(end), isSupportedDate(end), end.timeIntervalSince1970 > 0 else { + throw GrokBillingError.invalidReset + } + if let start, !(start is NSNull) { + guard let start = date(start), isSupportedDate(start), start < end else { + throw GrokBillingError.invalidReset + } + return (start, end) + } + return (nil, end) + } + + private static func isSupportedDate(_ date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.isFinite + && date >= .distantPast && date <= .distantFuture + } + + private static func safeText(_ value: String?, limit: Int) -> String? { + guard let value else { return nil } + let clean = String(value.unicodeScalars.filter { + !CharacterSet.controlCharacters.contains($0) + && $0.properties.generalCategory != .format + }.prefix(limit)).trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? nil : clean + } +} + +actor GrokBillingClient { + private let timeout: TimeInterval + + init(timeout: TimeInterval = 10) { + self.timeout = timeout.isFinite ? min(10, max(0.01, timeout)) : 10 + } + + func fetch(executableURL: URL) async throws -> GrokAllowanceSnapshot { + try Task.checkCancellation() + let worker = Task.detached(priority: .utility) { [timeout] in + try GrokBillingProcess.fetch(executableURL: executableURL, timeout: timeout) + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + static func executableURL(selected: URL?) -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + return ([selected].compactMap { $0 } + [ + home.appendingPathComponent(".grok/bin/grok"), + URL(fileURLWithPath: "/opt/homebrew/bin/grok"), + URL(fileURLWithPath: "/usr/local/bin/grok") + ]).first(where: isExecutable)?.resolvingSymlinksInPath() + } + + static func isExecutable(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + let resolved = url.resolvingSymlinksInPath() + return (try? resolved.resourceValues(forKeys: [.isRegularFileKey]))? + .isRegularFile == true + && FileManager.default.isExecutableFile(atPath: resolved.path) + } +} + +private struct GrokBillingProcess { + private static let maximumOutputBytes = 1_048_576 + + static func fetch(executableURL: URL, timeout: TimeInterval) throws -> GrokAllowanceSnapshot { + try Task.checkCancellation() + guard GrokBillingClient.isExecutable(executableURL) else { + throw GrokBillingError.notFound + } + let deadline = ProcessInfo.processInfo.systemUptime + timeout + let cwd = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexLimits-Grok-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: cwd, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + defer { try? FileManager.default.removeItem(at: cwd) } + var input: [Int32] = [-1, -1] + var output: [Int32] = [-1, -1] + guard pipe(&input) == 0 else { throw GrokBillingError.failed } + defer { for fd in input where fd >= 0 { close(fd) } } + guard pipe(&output) == 0 else { throw GrokBillingError.failed } + defer { for fd in output where fd >= 0 { close(fd) } } + for fd in input + output { + guard fcntl(fd, F_SETFD, FD_CLOEXEC) != -1 else { throw GrokBillingError.failed } + } + // A closed child stdin must report EPIPE instead of terminating the app. + guard fcntl(input[1], F_SETNOSIGPIPE, 1) != -1 else { throw GrokBillingError.failed } + var actions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { throw GrokBillingError.failed } + defer { posix_spawn_file_actions_destroy(&actions) } + guard posix_spawnattr_init(&attributes) == 0 else { throw GrokBillingError.failed } + defer { posix_spawnattr_destroy(&attributes) } + for status in [ + posix_spawn_file_actions_adddup2(&actions, input[0], STDIN_FILENO), + posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO), + posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0), + posix_spawn_file_actions_addchdir_np(&actions, cwd.path), + posix_spawnattr_setpgroup(&attributes, 0), + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) + ] where status != 0 { throw GrokBillingError.failed } + let argv = [executableURL.path, "agent", "--no-leader", "stdio"].map { + $0.withCString { strdup($0) } + } + [nil] + let env = ProcessInfo.processInfo.environment.map { + "\($0.key)=\($0.value)".withCString { strdup($0) } + } + [nil] + defer { for string in argv + env { free(string) } } + let pid = try GrokProcessRegistry.shared.start { pid in + argv.withUnsafeBufferPointer { args in + env.withUnsafeBufferPointer { environment in + posix_spawn(&pid, executableURL.path, &actions, &attributes, args.baseAddress!, environment.baseAddress!) + } + } + } + close(input[0]); input[0] = -1 + close(output[1]); output[1] = -1 + defer { + close(input[1]); input[1] = -1 + GrokProcessRegistry.shared.stop(pid) + } + var buffer = Data() + var totalBytes = 0 + func checkDeadline() throws { + try Task.checkCancellation() + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GrokBillingError.timedOut } + } + func request(_ method: String, id: Int, params: [String: Any]) throws { + try checkDeadline() + var message = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", "id": id, "method": method, "params": params + ], options: [.withoutEscapingSlashes]) + message.append(10) + let written = message.withUnsafeBytes { bytes in + Darwin.write(input[1], bytes.baseAddress, bytes.count) + } + guard written == message.count else { throw GrokBillingError.connectionLost } + } + func response(id: Int) throws -> [String: Any] { + while true { + try checkDeadline() + if let newline = buffer.firstIndex(of: 10) { + let line = buffer.prefix(upTo: newline) + defer { buffer.removeSubrange(...newline) } + guard let object = try? JSONSerialization.jsonObject(with: line) + as? [String: Any], object["jsonrpc"] as? String == "2.0" else { + throw GrokBillingError.invalidResponse + } + guard object["id"] as? Int == id else { continue } + if let error = object["error"] as? [String: Any] { + switch error["code"] as? Int { + case -32601: throw GrokBillingError.unsupported + case -32000: throw GrokBillingError.authenticationRequired + default: throw GrokBillingError.failed + } + } + guard let result = object["result"] as? [String: Any] else { + throw GrokBillingError.invalidResponse + } + return result + } + var descriptor = pollfd(fd: output[0], events: Int16(POLLIN), revents: 0) + let ready = poll(&descriptor, 1, 25) + if ready < 0, errno == EINTR { continue } + guard ready >= 0 else { throw GrokBillingError.connectionLost } + guard ready > 0 else { continue } + var bytes = [UInt8](repeating: 0, count: 8_192) + let count = Darwin.read(output[0], &bytes, bytes.count) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw GrokBillingError.connectionLost } + totalBytes += count + guard totalBytes <= maximumOutputBytes else { throw GrokBillingError.invalidResponse } + buffer.append(contentsOf: bytes.prefix(count)) + } + } + try request("initialize", id: 1, params: [ + "protocolVersion": 1, + "clientCapabilities": ["fs": ["readTextFile": false, "writeTextFile": false], "terminal": false], + "clientInfo": ["name": "codex-limits", "version": "1"] + ]) + let initialized = try response(id: 1) + guard initialized["protocolVersion"] as? Int == 1 else { throw GrokBillingError.unsupported } + let version = (initialized["_meta"] as? [String: Any])?["agentVersion"] as? String + ?? (initialized["agentInfo"] as? [String: Any])?["version"] as? String + // ACP SDKs add this underscore automatically; raw JSON-RPC clients must include it. + try request("_x.ai/billing", id: 2, params: [:]) + let result = try response(id: 2) + return try GrokAllowanceSnapshot.decode( + JSONSerialization.data(withJSONObject: result), observedAt: Date(), sourceVersion: version + ) + } + +} + +private final class GrokProcessRegistry: @unchecked Sendable { + static let shared = GrokProcessRegistry() + + private let lock = NSLock() + private var processes: Set = [] + private var exiting = false + private let registered: Bool + + private init() { + registered = atexit { GrokProcessRegistry.shared.stopAll() } == 0 + } + + func start(_ spawn: (inout pid_t) -> Int32) throws -> pid_t { + try lock.withLock { + guard registered, !exiting else { throw GrokBillingError.connectionLost } + var pid: pid_t = 0 + guard spawn(&pid) == 0 else { throw GrokBillingError.failed } + processes.insert(pid) + return pid + } + } + + func stop(_ pid: pid_t) { + lock.withLock { + guard processes.remove(pid) != nil else { return } + Self.stopGroup(pid) + } + } + + private func stopAll() { + lock.withLock { + exiting = true + for pid in processes { Self.stopGroup(pid) } + processes.removeAll() + } + } + + private static func stopGroup(_ pid: pid_t) { + kill(-pid, SIGTERM) + let deadline = ProcessInfo.processInfo.systemUptime + 1 + repeat { + var info = siginfo_t() + let result = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + if result == 0, info.si_pid == pid { break } + if result == -1, errno == ECHILD { return } + usleep(10_000) + } while ProcessInfo.processInfo.systemUptime < deadline + // WNOWAIT reserves the leader's PID until the final group signal; + // another process cannot reuse it between observation and cleanup. + kill(-pid, SIGKILL) + var status: Int32 = 0 + while waitpid(pid, &status, 0) == -1 && errno == EINTR {} + } +} diff --git a/Sources/CodexLimits/GrokIntegration.swift b/Sources/CodexLimits/GrokIntegration.swift new file mode 100644 index 0000000..fc8916c --- /dev/null +++ b/Sources/CodexLimits/GrokIntegration.swift @@ -0,0 +1,505 @@ +import AppKit +import ClaudeIntegrationCore +import Combine +import Foundation + +private actor GrokSnapshotCache { + let url: URL + private var historyURL: URL { url.deletingLastPathComponent().appendingPathComponent("History") } + + init(url: URL) { self.url = url } + + func read(now: Date) throws -> GrokAllowanceSnapshot? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: 64 * 1_024 + 1) ?? Data() + guard data.count <= 64 * 1_024 else { + throw GrokBillingError.invalidResponse + } + let snapshot = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: data) + guard snapshot.isValid, snapshot.observedAt <= now.addingTimeInterval(60) else { + throw GrokBillingError.invalidResponse + } + return snapshot + } + + func write(_ snapshot: GrokAllowanceSnapshot) throws { + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + // The private directory also protects the atomic replacement before chmod. + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + try JSONEncoder().encode(snapshot).write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + func readHistory(now: Date, seed: GrokAllowanceSnapshot?) throws -> [AllowanceObservation] { + if let seed { try appendHistory(seed) } + return try AllowanceHistory.read(in: historyURL, now: now) + } + + func readOverview( + snapshot: GrokAllowanceSnapshot?, now: Date, safetyBuffer: Double + ) -> (snapshot: UsageOverviewSnapshot?, historyReadFailed: Bool) { + let current = snapshot?.historyObservation + guard let since = UsageOverviewSnapshot.historyReadStart(current: current, now: now) else { + return (nil, false) + } + do { + if let snapshot { try appendHistory(snapshot) } + let observations = try AllowanceHistory.read(in: historyURL, now: now, since: since) + return (UsageOverviewSnapshot( + observations: observations, current: current, now: now, safetyBuffer: safetyBuffer + ), false) + } catch { + return (UsageOverviewSnapshot( + observations: [], current: current, now: now, safetyBuffer: safetyBuffer + ), true) + } + } + + func appendHistory(_ snapshot: GrokAllowanceSnapshot, previous: GrokAllowanceSnapshot? = nil) throws { + try AllowanceHistory.append([previous, snapshot].compactMap { $0?.historyObservation }, in: historyURL) + } + + func exists() -> Bool { + FileManager.default.fileExists(atPath: url.path) + || FileManager.default.fileExists(atPath: historyURL.path) + } + + func delete() throws { + try AllowanceHistory.delete(in: historyURL) + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } +} + +@MainActor +final class GrokIntegrationStore: ObservableObject { + @Published private(set) var snapshot: GrokAllowanceSnapshot? + @Published private(set) var history: [AllowanceObservation] = [] + @Published private(set) var overview: UsageOverviewSnapshot? + @Published private(set) var historyIssue: String? + @Published private(set) var error: GrokBillingError? + @Published private(set) var storageIssue: String? + @Published private(set) var isRefreshing = false + @Published private(set) var hasStoredData = false + @Published private(set) var displayNow: Date + + private var enabled: Bool + private var menuBarSourceActive: Bool + private var visible = false + private var historyVisible = false + private var overviewSafetyBuffer: Double = 3 + private var overviewDemand: UInt64 = 0 + private var settingsVisible = false + private var selectedExecutableURL: URL? + private let cache: GrokSnapshotCache + private let coordinator: IntegrationWorkCoordinator + private let fetchUsage: @Sendable (URL) async throws -> GrokAllowanceSnapshot + private let now: @Sendable () -> Date + private let uptime: @Sendable () -> TimeInterval + private var cacheLoaded = false + private var historyLoaded = false + private var generation: UInt64 = 0 + private var request: Task? + private var requestPriority: IntegrationWorkPriority? + private var boundaryTask: Task? + private var observers: Set = [] + private var lastLaunchUptime: TimeInterval? + private var nextRefreshAt = Date.distantPast + private var failures = 0 + + init( + isEnabled: Bool, + menuBarSourceActive: Bool = false, + selectedExecutableURL: URL? = nil, + cacheURL: URL? = nil, + integrationWorkCoordinator: IntegrationWorkCoordinator = IntegrationWorkCoordinator(), + fetchUsage: @escaping @Sendable (URL) async throws -> GrokAllowanceSnapshot = { + try await GrokBillingClient().fetch(executableURL: $0) + }, + now: @escaping @Sendable () -> Date = { Date() }, + uptime: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { + enabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive + self.selectedExecutableURL = selectedExecutableURL + self.coordinator = integrationWorkCoordinator + self.fetchUsage = fetchUsage + self.now = now + self.uptime = uptime + displayNow = now() + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + self.cache = GrokSnapshotCache(url: cacheURL ?? support + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.github.thrr87.CodexLimits") + .appendingPathComponent("Integrations/Grok/snapshot.json")) + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didWakeNotification) + .merge(with: NotificationCenter.default.publisher(for: .NSSystemClockDidChange)) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.updateDisplayTime() + if self.enabled, self.menuBarSourceActive { + await self.refresh(force: false, priority: .automatic) + } + } + } + .store(in: &observers) + if isEnabled, menuBarSourceActive { + Task { [weak self] in await self?.refresh(force: false, priority: .automatic) } + } + } + + var currentSnapshot: GrokAllowanceSnapshot? { + snapshot.flatMap { $0.resetsAt > displayNow ? $0 : nil } + } + + var isStale: Bool { + guard let snapshot = currentSnapshot else { return false } + return error != nil || displayNow.timeIntervalSince(snapshot.observedAt) >= 30 * 60 + || snapshot.observedAt > displayNow.addingTimeInterval(60) + } + + var menuBarText: String { + currentSnapshot.map { "\(Int($0.remainingPercent.rounded()))%" } ?? "—" + } + + var statusText: String { + if isRefreshing { return "Checking" } + if let error { return error.localizedDescription } + if snapshot != nil, currentSnapshot == nil { return "New usage observation needed" } + return snapshot == nil ? "Ready to check" : (isStale ? "Stale" : "Ready") + } + + var canRefresh: Bool { + enabled && !isRefreshing && (lastLaunchUptime.map { uptime() - $0 >= 30 } ?? true) + } + + func settingsPresented() async { + guard !Task.isCancelled else { return } + settingsVisible = true + updateDisplayTime() + let expected = generation + let selected = selectedExecutableURL + await coordinator.run(priority: .settings) { @MainActor [weak self] in + guard let self, self.generation == expected, self.settingsVisible else { return } + let exists = await self.cache.exists() + guard self.generation == expected, self.settingsVisible else { return } + self.hasStoredData = exists + guard self.enabled else { return } + let executable = await Task.detached { GrokBillingClient.executableURL(selected: selected) }.value + guard self.generation == expected, self.settingsVisible else { return } + if executable == nil { self.error = .notFound } + else if self.error == .notFound { self.error = nil } + } + } + + func settingsDismissed() { + settingsVisible = false + updateDisplayTime() + } + + func setEnabled(_ enabled: Bool) async { + guard self.enabled != enabled else { return } + self.enabled = enabled + if !enabled { + history = [] + overview = nil + historyLoaded = false + } + let expected = generation &+ 1 + await cancelRequest() + guard generation == expected else { return } + if enabled { await refresh(force: false, priority: .explicit) } + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard menuBarSourceActive != active else { return } + menuBarSourceActive = active + if !active, !visible, requestPriority != .explicit { await cancelRequest() } + updateDisplayTime() + if active { await refresh(force: false, priority: .automatic) } + } + + func setVisible(_ visible: Bool, includeHistory: Bool = true, safetyBuffer: Double = 3) async { + guard !Task.isCancelled else { return } + let historyVisible = visible && includeHistory + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + guard self.visible != visible || self.historyVisible != historyVisible + || overviewSafetyBuffer != buffer else { return } + self.visible = visible + self.historyVisible = historyVisible + overviewSafetyBuffer = buffer + overviewDemand &+= 1 + overview = nil + if !visible, !menuBarSourceActive, requestPriority != .explicit { await cancelRequest() } + if !historyVisible { + history = [] + historyLoaded = false + } + updateDisplayTime() + if visible { + await refresh(force: false, priority: .visible) + // A shared request may have started before All acquired demand. + if self.visible, !self.historyVisible, overview == nil { + let expected = generation + await coordinator.run(priority: .visible) { @MainActor [weak self] in + await self?.refreshOverview(expected: expected) + } + } + } + } + + func selectExecutable(_ url: URL) async -> Bool { + let initialGeneration = generation + let valid = await Task.detached { GrokBillingClient.isExecutable(url) }.value + guard valid, enabled, generation == initialGeneration else { return false } + selectedExecutableURL = url + let expected = generation &+ 1 + await cancelRequest() + guard enabled, generation == expected else { return false } + error = nil + nextRefreshAt = .distantPast + await refresh(force: true) + return enabled && generation == expected && selectedExecutableURL == url + } + + func refresh(force: Bool = true, priority: IntegrationWorkPriority = .explicit) async { + guard enabled, hasDemand(priority), !Task.isCancelled else { return } + if let request { + if priority == .explicit, requestPriority != .explicit, !isRefreshing { + let expected = generation &+ 1 + await cancelRequest() + guard enabled, generation == expected else { return } + } else { + await request.value + return + } + } + let expected = generation + requestPriority = priority + let task = Task { [weak self] in + guard let self else { return } + await self.coordinator.run(priority: priority) { @MainActor [weak self] in + guard let self, self.isCurrent(expected), self.hasDemand(priority) else { return } + if !self.cacheLoaded { + do { + let cached = try await self.cache.read(now: self.now()) + guard self.isCurrent(expected) else { return } + self.snapshot = cached + self.hasStoredData = cached != nil + if let cached { + self.nextRefreshAt = min(cached.observedAt.addingTimeInterval(600), cached.resetsAt) + } + } catch { + guard self.isCurrent(expected) else { return } + self.storageIssue = "Saved Grok usage couldn’t be read." + } + self.cacheLoaded = true + } + if self.historyVisible, !self.historyLoaded { + do { + let history = try await self.cache.readHistory(now: self.now(), seed: self.snapshot) + guard self.isCurrent(expected) else { return } + if self.historyVisible { + self.history = history + self.historyLoaded = true + self.hasStoredData = self.hasStoredData || !history.isEmpty + self.historyIssue = nil + } + } catch { + guard self.isCurrent(expected) else { return } + self.historyIssue = "Saved Grok history couldn’t be read." + } + } + await self.refreshOverview(expected: expected) + self.updateDisplayTime() + guard force || self.now() >= self.nextRefreshAt, + self.canRefresh else { return } + let selected = self.selectedExecutableURL + let executable = await Task.detached { GrokBillingClient.executableURL(selected: selected) }.value + guard self.isCurrent(expected), self.hasDemand(priority) else { return } + guard let executable else { + self.error = .notFound + self.nextRefreshAt = self.now().addingTimeInterval(600) + return + } + self.isRefreshing = true + self.lastLaunchUptime = self.uptime() + do { + let snapshot = try await self.fetchUsage(executable) + guard self.isCurrent(expected) else { return } + let previous = self.snapshot + self.snapshot = snapshot + self.error = nil + self.failures = 0 + self.nextRefreshAt = self.now().addingTimeInterval(600) + do { + try await self.cache.appendHistory(snapshot, previous: previous) + guard self.isCurrent(expected) else { return } + if self.historyVisible, !self.historyLoaded { + let history = try await self.cache.readHistory(now: self.now(), seed: nil) + guard self.isCurrent(expected) else { return } + if self.historyVisible { + self.history = history + self.historyLoaded = true + } + } else if self.historyVisible { + let observation = snapshot.historyObservation + if observation.isValid, !self.history.contains(observation) { + self.history.append(observation) + } + } + let start = self.now().addingTimeInterval(-84 * 86_400) + self.history.removeAll { $0.observedAt < start } + guard self.history.count <= AllowanceHistory.maximumReadRecords else { + self.history = [] + self.historyLoaded = false + throw AllowanceHistoryError.readLimitExceeded + } + self.historyIssue = nil + self.hasStoredData = true + } catch { + guard self.isCurrent(expected) else { return } + self.historyIssue = "Grok history couldn’t be updated on this Mac." + } + do { + try await self.cache.write(snapshot) + guard self.isCurrent(expected) else { return } + self.hasStoredData = true + self.storageIssue = nil + } catch { + guard self.isCurrent(expected) else { return } + self.storageIssue = "Grok usage couldn’t be saved on this Mac." + } + await self.refreshOverview(expected: expected) + } catch is CancellationError { + return + } catch { + guard self.isCurrent(expected) else { return } + self.error = error as? GrokBillingError ?? .failed + self.failures = min(self.failures + 1, 4) + self.nextRefreshAt = self.now().addingTimeInterval(min(600 * pow(2, Double(self.failures - 1)), 3_600)) + } + } + guard self.generation == expected else { return } + self.isRefreshing = false + self.request = nil + self.requestPriority = nil + self.updateDisplayTime() + } + request = task + await task.value + } + + func deleteData() async { + enabled = false + menuBarSourceActive = false + visible = false + historyVisible = false + overview = nil + overviewDemand &+= 1 + selectedExecutableURL = nil + let expected = generation &+ 1 + await cancelRequest() + await coordinator.run(priority: .explicit) { @MainActor [weak self] in + guard let self, self.generation == expected, !self.enabled else { return } + do { + try await self.cache.delete() + guard self.generation == expected else { return } + self.snapshot = nil + self.history = [] + self.historyLoaded = true + self.historyIssue = nil + self.hasStoredData = false + self.cacheLoaded = true + self.error = nil + self.storageIssue = nil + self.nextRefreshAt = .distantPast + } catch { + guard self.generation == expected else { return } + self.storageIssue = "Grok usage couldn’t be deleted. Try again." + } + self.updateDisplayTime() + } + } + + func updateDisplayTime() { + displayNow = now() + if let overview, overview.range.end <= displayNow { self.overview = nil } + scheduleBoundary() + } + + private func refreshOverview(expected: UInt64) async { + guard isCurrent(expected), visible, !historyVisible else { return } + let demand = overviewDemand + let current = snapshot + let result = await cache.readOverview( + snapshot: current, now: now(), safetyBuffer: overviewSafetyBuffer + ) + guard isCurrent(expected), visible, !historyVisible, + overviewDemand == demand, snapshot == current else { return } + overview = result.snapshot + if result.historyReadFailed { historyIssue = "Saved Grok history couldn’t be read." } + else if current != nil { historyIssue = nil } + } + + private func hasDemand(_ priority: IntegrationWorkPriority) -> Bool { + switch priority { + case .explicit, .settings: true + case .visible: visible + case .automatic: menuBarSourceActive + } + } + + private func isCurrent(_ expected: UInt64) -> Bool { + generation == expected && enabled && !Task.isCancelled + } + + private func cancelRequest() async { + generation &+= 1 + boundaryTask?.cancel() + boundaryTask = nil + let pending = request + request = nil + requestPriority = nil + isRefreshing = false + pending?.cancel() + await pending?.value + } + + private func scheduleBoundary() { + boundaryTask?.cancel() + boundaryTask = nil + guard enabled, menuBarSourceActive || visible || settingsVisible else { return } + let now = now() + let cooldown = lastLaunchUptime.map { max(0, 30 - (uptime() - $0)) } ?? 0 + var dates = [snapshot?.observedAt.addingTimeInterval(30 * 60), snapshot?.resetsAt] + .compactMap { $0 }.filter { $0 > now } + if cooldown > 0 { dates.append(now.addingTimeInterval(cooldown)) } + if menuBarSourceActive, request == nil { + dates.append(max(nextRefreshAt, now.addingTimeInterval(max(cooldown, 0.1)))) + } + guard let boundary = dates.min() else { return } + boundaryTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(boundary.timeIntervalSince(now))) } + catch { return } + guard let self else { return } + self.displayNow = self.now() + if let overview = self.overview, overview.range.end <= self.displayNow { self.overview = nil } + if self.menuBarSourceActive { await self.refresh(force: false, priority: .automatic) } + else { self.scheduleBoundary() } + } + } + + deinit { + request?.cancel() + boundaryTask?.cancel() + } +} diff --git a/Sources/CodexLimits/IntegrationAllowanceChart.swift b/Sources/CodexLimits/IntegrationAllowanceChart.swift new file mode 100644 index 0000000..4577534 --- /dev/null +++ b/Sources/CodexLimits/IntegrationAllowanceChart.swift @@ -0,0 +1,134 @@ +import ClaudeIntegrationCore +import Foundation + +extension GrokAllowanceSnapshot { + var historyObservation: AllowanceObservation { + AllowanceObservation( + metric: period == .weekly ? "grok-weekly" : "grok-monthly", + observedAt: observedAt, + remainingPercent: remainingPercent, + resetsAt: resetsAt, + startsAt: startsAt ?? (period == .weekly + ? resetsAt.addingTimeInterval(-7 * 86_400) : nil), + source: measurementSource + ) + } +} + +/// Provider observations feed the same chart as Codex, without token estimates. +struct IntegrationAllowanceChart: Sendable { + let window: UsageWindow + let chart: UsageChartSnapshot + let evidence: UsageEvidence + let forecastUnavailableReason: String? + + init?( + metric: String, + observations: [AllowanceObservation], + current: AllowanceObservation?, + now: Date, + isStale: Bool, + safetyBuffer: Double + ) { + guard !Task.isCancelled else { return nil } + let values = (observations + [current].compactMap { $0 }).filter { + $0.metric == metric && $0.isValid && $0.observedAt <= now + } + let grouped = Dictionary(grouping: values, by: \.observedAt) + let samples = grouped.values.compactMap { readings -> UsageSample? in + guard let value = readings.last else { return nil } + return UsageSample( + observedAt: value.observedAt, + remainingPercent: value.remainingPercent, + resetsAt: value.resetsAt, + comparisonBreak: readings.contains { + $0.remainingPercent != value.remainingPercent || $0.resetsAt != value.resetsAt + || $0.startsAt != value.startsAt || $0.source != value.source + } + ) + }.sorted { $0.observedAt < $1.observedAt } + guard let latest = samples.last else { return nil } + guard !Task.isCancelled else { return nil } + let latestValue = grouped[latest.observedAt]?.last + let start = latestValue?.startsAt + let first = samples.first { $0.resetsAt == latest.resetsAt } ?? latest + window = UsageWindow( + remainingPercent: latest.remainingPercent, + resetsAt: latest.resetsAt, + durationMinutes: max(1, Int(ceil(latest.resetsAt.timeIntervalSince(start ?? first.observedAt) / 60))) + ) + guard window.isValid else { return nil } + + var segments: [[UsageSample]] = [] + for sample in samples { + guard !Task.isCancelled else { return nil } + if let previous = segments.last?.last, + !sample.comparisonBreak, + !previous.comparisonBreak, + sample.resetsAt == previous.resetsAt, + grouped[sample.observedAt]?.last?.startsAt == grouped[previous.observedAt]?.last?.startsAt, + grouped[sample.observedAt]?.last?.source == grouped[previous.observedAt]?.last?.source, + sample.remainingPercent <= previous.remainingPercent + UsageHistoryPolicy.correctionTolerance, + sample.observedAt.timeIntervalSince(previous.observedAt) <= UsageHistoryPolicy.maximumComparableGap { + segments[segments.count - 1].append(sample) + } else { + segments.append([sample]) + } + } + let windows = Dictionary(grouping: segments, by: { $0[0].resetsAt }) + .map { reset, segments in + UsageAllowanceWindowSeries( + resetsAt: reset, + observedSegments: segments.map { segment in + segment.map { UsageChartPoint(date: $0.observedAt, remaining: $0.remainingPercent) } + } + ) + }.sorted { $0.resetsAt < $1.resetsAt } + + let recent = (segments.last ?? []).filter { $0.observedAt >= now.addingTimeInterval(-86_400) } + var projection: [UsageChartPoint] = [] + let hasCurrent = current.map { + $0.isValid && $0.metric == metric && $0.observedAt == latest.observedAt + && $0.resetsAt == latest.resetsAt && $0.remainingPercent == latest.remainingPercent + } ?? false + if hasCurrent, !isStale, latest.resetsAt > now, + now.timeIntervalSince(latest.observedAt) < UsageHistoryPolicy.maximumComparableGap, + let first = recent.first, recent.count >= 2, + latest.observedAt.timeIntervalSince(first.observedAt) >= 60 { + let days = latest.observedAt.timeIntervalSince(first.observedAt) / 86_400 + let rate = max(0, (first.remainingPercent - latest.remainingPercent) / days) + if rate.isFinite { + projection = UsageIntelligenceEngine.projection( + reading: latest, window: window, rate: rate, + remainingAtReset: max(0, latest.remainingPercent - rate * latest.resetsAt.timeIntervalSince(latest.observedAt) / 86_400) + ) + } + } + let buffer = SafetyBufferPolicy.normalized(safetyBuffer) + let target = start.map { + [UsageChartPoint(date: $0, remaining: 100), UsageChartPoint(date: latest.resetsAt, remaining: buffer)] + } ?? [] + chart = UsageChartSnapshot( + observedSource: .account, + target: target, + currentProjection: projection, + currentAllowanceReset: latest.resetsAt, + allowanceWindows: windows, + currentRunsFaster: projection.last.map { $0.remaining < buffer } ?? false, + accessibilityValue: "Last observed \(Int(latest.remainingPercent.rounded())) percent remaining. " + + (projection.isEmpty ? "A forecast is not available." : "Current estimate follows the observed usage pace.") + ) + evidence = UsageEvidence( + coverage: .partial, + confidence: projection.isEmpty ? .unavailable : .low, + reason: nil, + policyVersion: 1 + ) + forecastUnavailableReason = !projection.isEmpty ? nil + : !hasCurrent || latest.resetsAt <= now + ? "A current usage observation is needed for an estimate." + : isStale || now.timeIntervalSince(latest.observedAt) >= UsageHistoryPolicy.maximumComparableGap + ? "Usage is stale. A new observation is needed for an estimate." + : "An estimate needs at least two recent observations from the current period." + } +} diff --git a/Sources/CodexLimits/IntegrationPreferences.swift b/Sources/CodexLimits/IntegrationPreferences.swift new file mode 100644 index 0000000..8a4be26 --- /dev/null +++ b/Sources/CodexLimits/IntegrationPreferences.swift @@ -0,0 +1,168 @@ +import Foundation + +enum IntegrationID: String, CaseIterable, Codable, Identifiable, Sendable { + case codex + case claudeCode + case grok + + var id: String { rawValue } + + var displayName: String { + switch self { + case .codex: "Codex" + case .claudeCode: "Claude Code" + case .grok: "Grok" + } + } +} + +enum MenuBarMetric: String, CaseIterable, Codable, Identifiable, Sendable { + case none + case codexWeeklyUsageRemaining + case claudeSevenDayUsageRemaining + case grokCurrentPeriodUsageRemaining + + var id: String { rawValue } + + var integration: IntegrationID? { + switch self { + case .none: nil + case .codexWeeklyUsageRemaining: .codex + case .claudeSevenDayUsageRemaining: .claudeCode + case .grokCurrentPeriodUsageRemaining: .grok + } + } + + var displayName: String { + switch self { + case .none: "None" + case .codexWeeklyUsageRemaining: + "Codex — Weekly usage remaining" + case .claudeSevenDayUsageRemaining: + "Claude Code — 7-day usage remaining" + case .grokCurrentPeriodUsageRemaining: + "Grok — Current-period usage remaining" + } + } +} + +@MainActor +final class IntegrationPreferences: ObservableObject { + static let persistenceKey = "integrationPreferences" + + @Published private(set) var enabledIntegrations: Set + @Published private(set) var menuBarMetric: MenuBarMetric + @Published private(set) var codexExecutablePath: String? + @Published private(set) var claudeExecutablePath: String? + @Published private(set) var grokExecutablePath: String? + + private struct Stored: Codable { + let version: Int + let enabledIntegrationIDs: [String] + let menuBarMetricID: String + let codexExecutablePath: String? + let claudeExecutablePath: String? + let grokExecutablePath: String? + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.persistenceKey), + let stored = try? JSONDecoder().decode(Stored.self, from: data), + stored.version == 1 { + enabledIntegrations = Set( + stored.enabledIntegrationIDs.compactMap(IntegrationID.init) + ) + menuBarMetric = MenuBarMetric(rawValue: stored.menuBarMetricID) + ?? .none + if let integration = menuBarMetric.integration, + !enabledIntegrations.contains(integration) { + menuBarMetric = .none + } + codexExecutablePath = stored.codexExecutablePath + claudeExecutablePath = stored.claudeExecutablePath + grokExecutablePath = stored.grokExecutablePath + } else { + enabledIntegrations = [.codex] + menuBarMetric = .codexWeeklyUsageRemaining + codexExecutablePath = nil + claudeExecutablePath = nil + grokExecutablePath = nil + } + } + + func isEnabled(_ integration: IntegrationID) -> Bool { + enabledIntegrations.contains(integration) + } + + func setEnabled(_ enabled: Bool, for integration: IntegrationID) { + if enabled { + enabledIntegrations.insert(integration) + } else { + enabledIntegrations.remove(integration) + if menuBarMetric.integration == integration { + menuBarMetric = .none + } + } + persist() + } + + func selectMenuBarMetric(_ metric: MenuBarMetric) { + guard metric.integration.map(enabledIntegrations.contains) ?? true else { + return + } + menuBarMetric = metric + persist() + } + + var availableMenuBarMetrics: [MenuBarMetric] { + MenuBarMetric.allCases.filter { + $0.integration.map(enabledIntegrations.contains) ?? true + } + } + + var claudeExecutableURL: URL? { + claudeExecutablePath.map(URL.init(fileURLWithPath:)) + } + + var codexExecutableURL: URL? { + codexExecutablePath.map(URL.init(fileURLWithPath:)) + } + + var grokExecutableURL: URL? { + grokExecutablePath.map(URL.init(fileURLWithPath:)) + } + + func selectCodexExecutable(_ url: URL?) { + codexExecutablePath = url?.standardizedFileURL.path + persist() + } + + func selectClaudeExecutable(_ url: URL?) { + claudeExecutablePath = url?.standardizedFileURL.path + persist() + } + + func selectGrokExecutable(_ url: URL?) { + grokExecutablePath = url?.standardizedFileURL.path + persist() + } + + private func persist() { + let stored = Stored( + version: 1, + enabledIntegrationIDs: enabledIntegrations + .map(\.rawValue) + .sorted(), + menuBarMetricID: menuBarMetric.rawValue, + codexExecutablePath: codexExecutablePath, + claudeExecutablePath: claudeExecutablePath, + grokExecutablePath: grokExecutablePath + ) + if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: Self.persistenceKey) + } + } +} diff --git a/Sources/CodexLimits/LocalCoverageEvaluator.swift b/Sources/CodexLimits/LocalCoverageEvaluator.swift deleted file mode 100644 index 8ba44f5..0000000 --- a/Sources/CodexLimits/LocalCoverageEvaluator.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum LocalCoverageUnavailableReason: String, Codable, Equatable, Sendable { - case tokenDefinitionsNotProvenCompatible -} - -struct LocalCoverageEvaluation: Equatable, Sendable { - let comparable: Bool - let numericPercent: Double? - let reason: LocalCoverageUnavailableReason? - - static let unavailable = LocalCoverageEvaluation( - comparable: false, - numericPercent: nil, - reason: .tokenDefinitionsNotProvenCompatible - ) -} diff --git a/Sources/CodexLimits/LocalTokenActivity.swift b/Sources/CodexLimits/LocalTokenActivity.swift index afc8515..ea6f3b8 100644 --- a/Sources/CodexLimits/LocalTokenActivity.swift +++ b/Sources/CodexLimits/LocalTokenActivity.swift @@ -45,7 +45,6 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { let sourceVersion: String? let observedAt: Date? let points: [LocalTokenActivityPoint] - let accountComparison: LocalCoverageEvaluation static func unavailable( _ reason: String, @@ -58,8 +57,7 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { reason: reason, sourceVersion: nil, observedAt: nil, - points: [], - accountComparison: .unavailable + points: [] ) } @@ -94,8 +92,7 @@ struct LocalTokenActivitySnapshot: Equatable, Sendable { reason: updatedReason, sourceVersion: source.version, observedAt: source.observedAt, - points: points, - accountComparison: accountComparison + points: points ) } @@ -194,8 +191,7 @@ enum LocalTokenActivityAggregator { reason: "Local token total is invalid", sourceVersion: sourceVersion(observation), observedAt: observedAt(observation), - points: [], - accountComparison: .unavailable + points: [] ) } total = addition.partialValue @@ -238,8 +234,7 @@ enum LocalTokenActivityAggregator { reason: reason, sourceVersion: sourceVersion(observation), observedAt: observedAt(observation), - points: points, - accountComparison: .unavailable + points: points ) } diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index f9dbdd3..269717f 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1,20 +1,44 @@ import AppKit import Charts +import ClaudeIntegrationCore import SwiftUI +private enum IntegrationDestination: String, Identifiable { + case all = "All" + case codex = "Codex" + case claudeCode = "Claude Code" + case grok = "Grok" + + var id: String { rawValue } +} + struct MenuContentView: View { @ObservedObject var monitor: UsageMonitor + @ObservedObject var integrations: IntegrationPreferences + @ObservedObject var claudeCode: ClaudeCodeIntegrationStore + @ObservedObject var grok: GrokIntegrationStore @StateObject private var workspace: AnalyticsWorkspaceStore @StateObject private var assistedInsights: CodexAssistedInsightStore @StateObject private var updater = AppUpdater() + @State private var destination: IntegrationDestination = .all + @AppStorage private var safetyBuffer: Double @Environment(\.openSettings) private var openSettings + private let chartDefaults: UserDefaults init( monitor: UsageMonitor, + integrations: IntegrationPreferences, + claudeCode: ClaudeCodeIntegrationStore, + grok: GrokIntegrationStore, defaults: UserDefaults = .standard, assistedInsights: CodexAssistedInsightStore? = nil ) { self.monitor = monitor + self.integrations = integrations + self.claudeCode = claudeCode + self.grok = grok + self.chartDefaults = defaults + _safetyBuffer = AppStorage(wrappedValue: 3, UsageMonitor.safetyBufferKey, store: defaults) _workspace = StateObject( wrappedValue: AnalyticsWorkspaceStore(defaults: defaults) ) @@ -24,39 +48,19 @@ struct MenuContentView: View { } var body: some View { - let layout = currentLayout - VStack(spacing: 0) { - WorkspaceHeader( - reader: monitor.readerSnapshot, - isRefreshing: monitor.isRefreshing, - isCompact: layout.isCompact, - resetReminderState: monitor.resetReminderState, - refresh: { - Task { await monitor.refresh() } - }, - setResetReminderEnabled: { isEnabled in - Task { - await monitor.setResetReminderEnabled(isEnabled) - } - }, - availableUpdateVersion: updater.availableVersion, - showAvailableUpdate: updater.showAvailableUpdate, - settings: showSettings - ) - .padding(.horizontal, 20) - .padding(.vertical, 16) - - Divider() + if availableDestinations.count == 1 { + noIntegrationsWorkspace + } else { + enabledWorkspace + } + } - Picker( - "View", - selection: Binding( - get: { workspace.state.section }, - set: workspace.selectSection - ) - ) { - ForEach(AnalyticsSection.allCases) { section in - Text(section.rawValue).tag(section) + private var enabledWorkspace: some View { + let layout = currentLayout + return VStack(spacing: 0) { + Picker("Integration", selection: $destination) { + ForEach(availableDestinations) { destination in + Text(destination.rawValue).tag(destination) } } .pickerStyle(.segmented) @@ -66,11 +70,16 @@ struct MenuContentView: View { Divider() - ScrollView { - workspaceContent - .padding(20) + switch effectiveDestination { + case .all: + integrationOverview + case .codex: + codexWorkspace + case .claudeCode: + claudeCodeWorkspace + case .grok: + grokWorkspace } - .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() workspaceFooter @@ -81,19 +90,688 @@ struct MenuContentView: View { .task { updater.start() } + .task(id: effectiveDestination) { + await updateVisibleIntegrationWork() + } .task(id: workspace.state) { - let state = workspace.state - await monitor.setLocalAnalyticsVisible( - state.usesLocalAnalytics + guard effectiveDestination == .codex else { return } + await updateCodexWorkspaceWork() + } + .task(id: integrations.enabledIntegrations) { + await updateVisibleIntegrationWork() + } + .task(id: safetyBuffer) { + guard effectiveDestination == .all else { return } + await updateVisibleIntegrationWork() + } + .onChange(of: integrations.enabledIntegrations) { _, _ in + if !availableDestinations.contains(destination) { + destination = .all + } + } + .onDisappear { + Task { + await monitor.setVisible(false) + await monitor.setLocalAnalyticsVisible(false) + await claudeCode.setVisible(false) + await grok.setVisible(false) + } + } + .environment(\.locale, Locale(identifier: "en_US")) + } + + private var codexWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Codex") + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let weekly = monitor.readerSnapshot.weeklyUsageRemaining { + Text("Usage remaining · \(Int(weekly.window.remainingPercent.rounded()))%") + .font(.title.weight(.semibold)) + .monospacedDigit() + Text("Weekly · Resets \(weekly.window.resetsAt.formatted(date: .abbreviated, time: .shortened))") + .foregroundStyle(.secondary) + } + if workspace.state.section == .graphs, + workspace.state.graph == .usageRemaining, + (workspace.state.timeRange == .twelveWeeks + || monitor.historicalRange != nil) { + historicalRangeControls + } + workspaceContent + Divider() + TimelineView(.periodic(from: .now, by: 60)) { context in + Text(monitor.readerSnapshot.updatedText(at: context.date)) + .font(.callout) + .foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button("Refresh") { Task { await monitor.refresh() } } + .disabled(monitor.isRefreshing) + if monitor.isRefreshing { + ProgressView().controlSize(.small) + .accessibilityLabel("Checking Codex usage") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func integrationHeader(_ name: String, maturity: String? = nil) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(name).font(.title2.weight(.semibold)) + if let maturity { + Text(maturity).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if name == "Codex" { + Menu("More") { + ForEach(AnalyticsGraph.coreCases) { graph in + Button(graph.rawValue) { + workspace.selectGraph(graph) + workspace.selectSection(.graphs) + } + } + Divider() + Button("Facts & reset reminders") { workspace.selectSection(.facts) } + Button("Insights") { workspace.selectSection(.insights) } + if let version = updater.availableVersion { + Divider() + Button("Upgrade to \(version)", action: updater.showAvailableUpdate) + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel("More Codex views") + } + Button("Settings", action: showSettings).buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + + private var integrationOverview: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if integrations.isEnabled(.codex) { + integrationOverviewRow( + name: "Codex", + primary: monitor.readerSnapshot.weeklyUsageRemaining.map { + "Usage remaining · \(Int($0.window.remainingPercent.rounded()))%" + } ?? "Usage is not available", + secondary: monitor.readerSnapshot.sourceMessage + ?? monitor.readerSnapshot.weeklyUsageRemaining.map { + "Resets \($0.window.resetsAt.formatted(date: .abbreviated, time: .shortened))" + }, + status: monitor.isRefreshing ? "Checking" + : (monitor.readerSnapshot.freshness == .stale ? "Stale" : nil), + overview: monitor.readerSnapshot.weeklyUsageRemaining.flatMap { + UsageOverviewSnapshot( + chart: monitor.readerSnapshot.chart, window: $0.window, now: Date() + ) + }, + destination: .codex + ) + } + if integrations.isEnabled(.claudeCode) { + integrationOverviewRow( + name: "Claude Code", + primary: claudeOverviewPrimary, + secondary: claudeOverviewSecondary, + status: claudeOverviewTrailing, + overview: claudeCode.overview, + destination: .claudeCode + ) + } + if integrations.isEnabled(.grok) { + integrationOverviewRow( + name: "Grok", + primary: grokOverviewPrimary, + secondary: grok.error?.localizedDescription + ?? grok.currentSnapshot.map { + "\($0.period == .weekly ? "Weekly" : "Monthly") · Resets \($0.resetsAt.formatted(date: .abbreviated, time: .shortened))" + }, + status: grok.isRefreshing ? "Checking" : (grok.isStale ? "Stale" : nil), + overview: grok.overview, + destination: .grok + ) + } + } + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var historicalRangeControls: some View { + HStack(spacing: 12) { + Button { + Task { + await monitor.loadEarlierHistory( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + selectLoadedHistoricalRange() + } + } label: { + Label("Earlier", systemImage: "chevron.left") + } + .disabled( + monitor.isLoadingHistoricalRange + || !monitor.canLoadEarlierHistory ) - if state.section == .graphs, - state.graph == .tokenActivity { - await monitor.refreshAccountIfStale() + + if let range = monitor.historicalRange { + Text( + "\(range.start.formatted(date: .abbreviated, time: .omitted))–\(range.end.formatted(date: .abbreviated, time: .omitted))" + ) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + Button { + Task { + await monitor.loadLaterHistory( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + selectLoadedHistoricalRange() + } + } label: { + Label("Later", systemImage: "chevron.right") + } + .disabled(monitor.isLoadingHistoricalRange) + + Button("Latest") { + monitor.clearHistoricalRange() + workspace.selectTimeRange(.twelveWeeks) + } + } + + if monitor.isLoadingHistoricalRange { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading history range") + } + + Spacer() + + if let issue = monitor.historicalRangeIssue { + Text(issue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.borderless) + } + + private var claudeCodeWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Claude Code", maturity: "Experimental") + + Divider() + + ScrollView { + claudeCodeContent + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var grokOverviewPrimary: String { + if let snapshot = grok.currentSnapshot { + return "Usage remaining · \(Int(snapshot.remainingPercent.rounded()))%" + } + if grok.snapshot != nil { return "New usage observation needed" } + return grok.isRefreshing ? "Checking" : "Usage is not available" + } + + private var grokWorkspace: some View { + VStack(spacing: 0) { + integrationHeader("Grok", maturity: "Beta") + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(grokOverviewPrimary) + .font(.title.weight(.semibold)) + .monospacedDigit() + if let snapshot = grok.currentSnapshot { + Text("\(snapshot.period == .weekly ? "Weekly" : "Monthly") · Resets \(snapshot.resetsAt.formatted(date: .abbreviated, time: .shortened))") + .foregroundStyle(.secondary) + if snapshot.isUnifiedBilling == true { + Text("Shared across Grok products.") + .font(.callout).foregroundStyle(.secondary) + } + } + IntegrationUsageRemainingView( + title: "Usage remaining", + metric: grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric ?? "grok-weekly", + observations: grok.history, + current: grok.snapshot?.historyObservation, + now: grok.displayNow, + isStale: grok.isStale, + defaults: chartDefaults + ) + .id(grok.snapshot?.historyObservation.metric ?? grok.history.last?.metric) + if let snapshot = grok.snapshot { + if let plan = snapshot.subscriptionTier { + LabeledContent("Plan", value: plan) + } + if let balance = snapshot.prepaidBalanceUSD { + LabeledContent("Prepaid balance", value: balance.formatted(.currency(code: "USD"))) + } + if let used = snapshot.onDemandUsedUSD { + LabeledContent("On-demand usage", value: used.formatted(.currency(code: "USD"))) + } + if let cap = snapshot.onDemandCapUSD { + LabeledContent("On-demand limit", value: cap.formatted(.currency(code: "USD"))) + } + Divider() + VStack(alignment: .leading, spacing: 6) { + Text("Last checked \(snapshot.observedAt.formatted(.relative(presentation: .named)))") + if grok.isStale { + Label("Stale", systemImage: "clock.badge.exclamationmark") + } + if let version = snapshot.sourceVersion { + Text("Grok Build \(version)").foregroundStyle(.secondary) + } + } + .font(.callout) + } + if let error = grok.error { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .font(.callout) + if error == .notFound || error == .authenticationRequired || error == .unsupported { + Button("Open Settings", action: showSettings) + } + } + if let issue = grok.storageIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + if let issue = grok.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button("Refresh") { Task { await grok.refresh() } } + .disabled(!grok.canRefresh) + .help("Checks are at least 30 seconds apart.") + if grok.isRefreshing { + ProgressView().controlSize(.small).accessibilityLabel("Checking Grok usage") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var noIntegrationsWorkspace: some View { + VStack(spacing: 0) { + WorkspaceMessage( + icon: "switch.2", + title: "No integrations enabled", + message: "Enable an integration to show usage." + ) { + Button("Open Settings", action: showSettings) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + workspaceFooter + .padding(.horizontal, 20) + .padding(.vertical, 12) } + .frame(width: 420, height: 460) .environment(\.locale, Locale(identifier: "en_US")) } + private var availableDestinations: [IntegrationDestination] { + [.all] + + (integrations.isEnabled(.codex) ? [.codex] : []) + + (integrations.isEnabled(.claudeCode) ? [.claudeCode] : []) + + (integrations.isEnabled(.grok) ? [.grok] : []) + } + + private var effectiveDestination: IntegrationDestination { + availableDestinations.contains(destination) ? destination : .all + } + + @ViewBuilder + private var claudeCodeContent: some View { + if let snapshot = claudeCode.snapshot { + VStack(alignment: .leading, spacing: 18) { + if let sevenDay = snapshot.sevenDay, + sevenDay.resetsAt > claudeCode.displayNow { + VStack(alignment: .leading, spacing: 8) { + Text( + "Usage remaining · \(Int(sevenDay.remainingPercent.rounded()))%" + ) + .font(.title.weight(.semibold)) + .monospacedDigit() + Text( + "7-day · Resets \(sevenDay.resetsAt.formatted(date: .abbreviated, time: .shortened))" + ) + .foregroundStyle(.secondary) + } + } else { + Text( + snapshot.sevenDay == nil + ? "7-day usage unavailable" + : "New usage observation needed" + ) + .font(.headline) + Text("Use Claude Code to record current usage.") + .foregroundStyle(.secondary) + } + + claudeUsageChart(title: "7-day usage remaining", metric: "claude-seven-day") + + if let fiveHour = snapshot.fiveHour, + fiveHour.resetsAt > claudeCode.displayNow { + LabeledContent( + "5-hour usage remaining", + value: "\(Int(fiveHour.remainingPercent.rounded()))%" + ) + .monospacedDigit() + Text( + "Resets \(fiveHour.resetsAt.formatted(date: .abbreviated, time: .shortened))" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + claudeUsageChart(title: "5-hour usage remaining", metric: "claude-five-hour") + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text( + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + ) + if claudeCode.displayFreshness == .stale { + Label("Stale", systemImage: "clock.badge.exclamationmark") + } + Text("Usage updates during Claude Code activity.") + .foregroundStyle(.secondary) + } + .font(.callout) + + if let issue = claudeSnapshotIssue { + Label(issue, systemImage: "exclamationmark.triangle") + .font(.callout) + Button("Open Settings", action: showSettings) + } + if let issue = claudeCode.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(alignment: .leading, spacing: 18) { + claudeUnavailableContent + claudeUsageChart(title: "7-day usage remaining", metric: "claude-seven-day") + claudeUsageChart(title: "5-hour usage remaining", metric: "claude-five-hour") + if let issue = claudeCode.historyIssue { + Text(issue).font(.callout).foregroundStyle(.secondary) + } + } + } + } + + private func claudeUsageChart(title: String, metric: String) -> some View { + IntegrationUsageRemainingView( + title: title, metric: metric, observations: claudeCode.history, + current: claudeCode.snapshot?.historyObservations.first { $0.metric == metric }, + now: claudeCode.displayNow, isStale: claudeCode.displayFreshness != .fresh, + defaults: chartDefaults + ) + } + + @ViewBuilder + private var claudeUnavailableContent: some View { + switch claudeCode.readiness { + case .checking: + WorkspaceMessage( + icon: "arrow.clockwise", + title: "Checking", + message: "Checking Claude Code setup." + ) { + ProgressView().controlSize(.small) + } + case .notFound: + WorkspaceMessage( + icon: "terminal", + title: "Claude Code not found", + message: "Install Claude Code, then check again in Settings." + ) { + Button("Open Settings", action: showSettings) + } + case .waitingForData: + WorkspaceMessage( + icon: "clock", + title: "Use Claude Code to record usage", + message: "Usage appears after the first response in a session." + ) { + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + } + case .conflict: + WorkspaceMessage( + icon: "exclamationmark.triangle", + title: "Existing status line", + message: "Codex Limits won’t change your Claude Code status line." + ) { + Button("Open Settings", action: showSettings) + } + case .manualCleanupRequired: + WorkspaceMessage( + icon: "exclamationmark.triangle", + title: "Setup changed", + message: "Remove the Codex Limits command from your Claude Code status line." + ) { + Button("Open Settings", action: showSettings) + } + case .setUp, .updateRequired, .failed, .disabled, .ready: + WorkspaceMessage( + icon: "gearshape", + title: "Set up Claude Code", + message: "Finish setup in Settings to record usage." + ) { + Button("Open Settings", action: showSettings) + } + } + } + + private var claudeOverviewPrimary: String { + guard let snapshot = claudeCode.snapshot else { + return claudeReadinessText + } + guard let sevenDay = snapshot.sevenDay else { + return "7-day usage unavailable" + } + guard sevenDay.resetsAt > claudeCode.displayNow else { + return "New usage observation needed" + } + return "Usage remaining · \(Int(sevenDay.remainingPercent.rounded()))%" + } + + private var claudeOverviewSecondary: String? { + guard let snapshot = claudeCode.snapshot else { return nil } + var details: [String] = [] + if let sevenDay = snapshot.sevenDay, sevenDay.resetsAt > claudeCode.displayNow { + details.append("Resets \(sevenDay.resetsAt.formatted(date: .abbreviated, time: .shortened))") + } + if let fiveHour = snapshot.fiveHour, fiveHour.resetsAt > claudeCode.displayNow { + details.append("5-hour remaining · \(Int(fiveHour.remainingPercent.rounded()))%") + } + return details.isEmpty ? nil : details.joined(separator: " · ") + } + + private var claudeOverviewTrailing: String? { + guard let snapshot = claudeCode.snapshot else { return nil } + if let issue = claudeSnapshotIssue { return issue } + return switch claudeCode.displayFreshness { + case .fresh: + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + case .stale: + "Stale" + case .expired: + "Last observed \(snapshot.observedAt.formatted(.relative(presentation: .named)))" + case nil: + nil + } + } + + private var claudeSnapshotIssue: String? { + switch claudeCode.readiness { + case .setUp: + "Set up in Settings" + case .conflict: + "Existing status line" + case .updateRequired: + "Update required" + case .failed: + "Claude Code usage couldn’t be read" + case .notFound: + "Claude Code not found" + default: + nil + } + } + + private var claudeReadinessText: String { + switch claudeCode.readiness { + case .notFound: "Not found" + case .waitingForData: "Waiting for data" + case .conflict: "Existing status line" + case .checking: "Checking" + default: "Set up" + } + } + + private func integrationOverviewRow( + name: String, + primary: String, + secondary: String?, + status: String?, + overview: UsageOverviewSnapshot?, + destination: IntegrationDestination + ) -> some View { + Button { + self.destination = destination + } label: { + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text(name).font(.headline) + Text(primary).monospacedDigit() + if let secondary { + Text(secondary) + .font(.caption) + .foregroundStyle(.secondary) + } + if let status { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if let overview { + UsageOverviewChart(snapshot: overview) + .frame(width: currentLayout.isCompact ? 110 : 150, height: 72) + } + } + .padding(14) + .background(.quaternary.opacity(0.65), in: RoundedRectangle(cornerRadius: 10)) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint("Open \(name) usage details.") + } + + private func updateVisibleIntegrationWork() async { + guard !Task.isCancelled else { return } + switch effectiveDestination { + case .all: + monitor.clearHistoricalRange() + await monitor.setVisible(integrations.isEnabled(.codex)) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible( + integrations.isEnabled(.claudeCode), includeHistory: false, + safetyBuffer: safetyBuffer + ) + guard !Task.isCancelled else { return } + await grok.setVisible( + integrations.isEnabled(.grok), includeHistory: false, safetyBuffer: safetyBuffer + ) + guard !Task.isCancelled else { return } + if integrations.isEnabled(.codex) { + await monitor.refreshAccountIfStale() + } + case .codex: + await monitor.setVisible(true) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(false) + await grok.setVisible(false) + guard !Task.isCancelled else { return } + await updateCodexWorkspaceWork() + case .claudeCode: + monitor.clearHistoricalRange() + await monitor.setVisible(false) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(true) + await grok.setVisible(false) + case .grok: + monitor.clearHistoricalRange() + await monitor.setVisible(false) + guard !Task.isCancelled else { return } + await monitor.setLocalAnalyticsVisible(false) + guard !Task.isCancelled else { return } + await claudeCode.setVisible(false) + await grok.setVisible(true) + } + } + + private func updateCodexWorkspaceWork() async { + let state = workspace.state + if state.timeRange != .selected { + monitor.clearHistoricalRange() + } + await monitor.setLocalAnalyticsVisible(state.usesLocalAnalytics) + await monitor.refreshAccountIfStale() + } + + private func selectLoadedHistoricalRange() { + guard let range = monitor.historicalRange, + let snapshot = monitor.historicalReaderSnapshot, + let window = snapshot.account?.mainLimit?.window else { return } + let current = DateInterval( + start: window.startsAt, + end: window.resetsAt + ) + workspace.selectVisibleRange( + range, + within: snapshot.chart.availableRange(including: current) + ) + } + @ViewBuilder private var workspaceContent: some View { let presentation = AnalyticsWorkspacePresentation.resolve( @@ -109,6 +787,7 @@ struct MenuContentView: View { TimelineView(.periodic(from: .now, by: 60)) { context in AnalyticsWorkspaceBody( reader: monitor.readerSnapshot, + historicalReader: monitor.historicalReaderSnapshot, store: workspace, assistedInsights: assistedInsights, now: context.date, @@ -222,6 +901,7 @@ struct AnalyticsWorkspacePresentationView: View { @MainActor struct AnalyticsWorkspaceBody: View { let reader: UsageReaderSnapshot + let historicalReader: UsageReaderSnapshot? @ObservedObject var store: AnalyticsWorkspaceStore @ObservedObject var assistedInsights: CodexAssistedInsightStore let now: Date @@ -232,6 +912,7 @@ struct AnalyticsWorkspaceBody: View { init( reader: UsageReaderSnapshot, + historicalReader: UsageReaderSnapshot? = nil, store: AnalyticsWorkspaceStore, assistedInsights: CodexAssistedInsightStore, now: Date = Date(), @@ -239,13 +920,13 @@ struct AnalyticsWorkspaceBody: View { resetReminderState: ResetReminderState = ResetReminderState( isEnabled: false, leadTime: .hours24, - authorization: .unknown, delivery: .off ), setResetReminderEnabled: @escaping (Bool) -> Void = { _ in }, setResetReminderLeadTime: @escaping (ResetReminderLeadTime) -> Void = { _ in } ) { self.reader = reader + self.historicalReader = historicalReader self.store = store self.assistedInsights = assistedInsights self.now = now @@ -260,7 +941,11 @@ struct AnalyticsWorkspaceBody: View { Group { switch store.state.section { case .graphs: - GraphsWorkspace(reader: reader, store: store, now: now) + GraphsWorkspace( + reader: historicalReader ?? reader, + store: store, + now: now + ) case .facts: FactsWorkspace( reader: reader, @@ -277,204 +962,22 @@ struct AnalyticsWorkspaceBody: View { ) } } - .onChange(of: store.state) { _, _ in - analyticsPreferencesChanged() - } - .onChange(of: store.insightDispositions) { _, _ in - analyticsPreferencesChanged() - } - .onChange(of: now) { _, _ in - guard store.state.section == .graphs, - store.state.graph == .tokenActivity else { return } - switch store.state.timeRange { - case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: - analyticsPreferencesChanged() - case .currentWindow, .selected: - break - } - } - } -} - -private struct WorkspaceHeader: View { - let reader: UsageReaderSnapshot - let isRefreshing: Bool - let isCompact: Bool - let resetReminderState: ResetReminderState - let refresh: () -> Void - let setResetReminderEnabled: (Bool) -> Void - let availableUpdateVersion: String? - let showAvailableUpdate: () -> Void - let settings: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - if let weekly = reader.weeklyUsageRemaining { - Text( - weekly.window.remainingPercent, - format: .number.precision(.fractionLength(0)) - ) - .font(.system(size: 34, weight: .semibold, design: .rounded)) - .monospacedDigit() - Text("% remaining") - .foregroundStyle(.secondary) - } else { - Text(reader.evidence.reason ?? "Weekly usage unavailable") - .font(.headline) - } - - Spacer() - - Button(action: refresh) { - if isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .help("Refresh") - .accessibilityLabel("Refresh usage") - - if let availableUpdateVersion { - Button(action: showAvailableUpdate) { - Image(systemName: "arrow.down.circle") - } - .buttonStyle(.borderless) - .help("Upgrade to \(availableUpdateVersion)") - .accessibilityLabel("Upgrade Codex Limits") - .accessibilityValue( - "Version \(availableUpdateVersion) is available" - ) - } - - Button(action: settings) { - Image(systemName: "gearshape") - } - .buttonStyle(.borderless) - .help("Settings") - .accessibilityLabel("Settings") - } - - if isCompact { - VStack(alignment: .leading, spacing: 7) { - headerFactRows - } - } else { - HStack(spacing: 22) { - headerFactRows - } - } - - if reader.weeklyUsageRemaining != nil { - VStack(alignment: .leading, spacing: 3) { - Text(reader.guidanceTitle) - .font(.callout.weight(.semibold)) - .foregroundStyle( - reader.guidance.map { statusColor($0.status) } - ?? .secondary - ) - Text(reader.guidanceMessage) - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(isCompact ? 2 : 1) - } - } - } - } - - @ViewBuilder - private var headerFactRows: some View { - if let weekly = reader.weeklyUsageRemaining { - HeaderFact( - label: "Reset", - value: weekly.window.resetsAt.formatted( - date: .abbreviated, - time: .shortened - ) - ) - } - if let summary = reader.bankedResets { - TimelineView(.periodic(from: .now, by: 60)) { context in - HStack(spacing: 5) { - HeaderFact( - label: "Banked resets", - value: summary.headerValue(at: context.date) - ) - .help(summary.inspectionText(at: context.date)) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Banked resets") - .accessibilityValue( - "\(summary.headerValue(at: context.date)) · \(summary.inspectionText(at: context.date))" - ) - - if summary.currentNextKnownExpiry(at: context.date) != nil - || resetReminderState.isEnabled { - Button { - setResetReminderEnabled( - !resetReminderState.isEnabled - ) - } label: { - Image( - systemName: resetReminderState.isEnabled - ? "bell.fill" - : "bell" - ) - } - .buttonStyle(.plain) - .foregroundStyle( - resetReminderState.isEnabled - ? Color.accentColor - : Color.secondary - ) - .help(resetReminderState.controlHelp) - .accessibilityLabel("Reset Reminder") - .accessibilityValue( - "\(resetReminderState.isEnabled ? "On" : "Off"). \(resetReminderState.statusText)" - ) - .accessibilityHint( - resetReminderState.isEnabled - ? "Turn reminder off." - : "Turn reminder on." - ) - } - } - } - } else { - HeaderFact(label: "Banked resets", value: "Unavailable") - } - TimelineView(.periodic(from: .now, by: 60)) { context in - HeaderFact( - label: "Freshness", - value: reader.updatedText(at: context.date) - .replacingOccurrences(of: "Updated ", with: "") - ) + .onChange(of: store.state) { _, _ in + analyticsPreferencesChanged() } - } - - private func statusColor(_ status: PaceStatus) -> Color { - switch status { - case .slowDown: .red - case .onTrack: .green - case .roomToUseMore: .blue + .onChange(of: store.insightDispositions) { _, _ in + analyticsPreferencesChanged() } - } -} - -private struct HeaderFact: View { - let label: String - let value: String - - var body: some View { - HStack(spacing: 5) { - Text(label) - .foregroundStyle(.secondary) - Text(value) - .monospacedDigit() + .onChange(of: now) { _, _ in + guard store.state.section == .graphs, + store.state.graph == .tokenActivity else { return } + switch store.state.timeRange { + case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: + analyticsPreferencesChanged() + case .currentWindow, .selected: + break + } } - .font(.caption) } } @@ -513,37 +1016,16 @@ private struct GraphsWorkspace: View { } private var graphToolbar: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { - graphPicker - rangePicker - scopeControl + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(store.state.graph.rawValue).font(.headline) Spacer() + rangePicker } - VStack(alignment: .leading, spacing: 10) { - graphPicker - HStack(spacing: 12) { - rangePicker - scopeControl - } - } - } - } - - private var graphPicker: some View { - Picker( - "Graph", - selection: Binding( - get: { store.state.graph }, - set: store.selectGraph - ) - ) { - ForEach(AnalyticsGraph.coreCases) { graph in - Text(graph.rawValue).tag(graph) + if !store.state.graph.usesAccountScope { + WorkspaceFilterMenu(reader: reader, store: store) } } - .frame(minWidth: 180) - .accessibilityLabel("Graph") } private var rangePicker: some View { @@ -560,23 +1042,10 @@ private struct GraphsWorkspace: View { Text(range.rawValue).tag(range) } } - .frame(minWidth: 130) + .frame(maxWidth: 200) .accessibilityLabel("Time range") } - @ViewBuilder - private var scopeControl: some View { - if store.state.graph.usesAccountScope { - Label("Account", systemImage: "person.crop.circle") - .font(.caption) - .foregroundStyle(.secondary) - .help("Data from your Codex account.") - .accessibilityLabel("Account scope") - } else { - WorkspaceFilterMenu(reader: reader, store: store) - } - } - @ViewBuilder private var usageRemaining: some View { if let weekly = reader.weeklyUsageRemaining @@ -590,43 +1059,53 @@ private struct GraphsWorkspace: View { now: now ) - Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { - GridRow { - Text("Reset") - .foregroundStyle(.secondary) - Text( - weekly.window.resetsAt.formatted( - date: .abbreviated, - time: .shortened - ) - ) - } - GridRow { - Text("Suggested pace") - .foregroundStyle(.secondary) - Text(reader.suggestedPaceText) - } - GridRow { - Text("Runway") - .foregroundStyle(.secondary) - Text(reader.runwayText) - } - if let gap = reader.guidance?.runway.gapText { - GridRow { - Text("Gap to reset") - .foregroundStyle(.secondary) - Text(gap) + DisclosureGroup("Usage details") { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(reader.guidanceTitle).fontWeight(.semibold) + Text(reader.guidanceMessage).foregroundStyle(.secondary) } - } - if let range = reader.guidance?.remainingAtResetRange { - GridRow { - Text("Range") - .foregroundStyle(.secondary) - Text(range.text) + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { + GridRow { + Text("Reset") + .foregroundStyle(.secondary) + Text( + weekly.window.resetsAt.formatted( + date: .abbreviated, + time: .shortened + ) + ) + } + GridRow { + Text("Suggested pace") + .foregroundStyle(.secondary) + Text(reader.suggestedPaceText) + } + GridRow { + Text("Runway") + .foregroundStyle(.secondary) + Text(reader.runwayText) + } + if let gap = reader.guidance?.runway.gapText { + GridRow { + Text("Gap to reset") + .foregroundStyle(.secondary) + Text(gap) + } + } + if let range = reader.guidance?.remainingAtResetRange { + GridRow { + Text("Range") + .foregroundStyle(.secondary) + Text(range.text) + } + } } + .font(.callout) } + .font(.callout) + .padding(.top, 8) } - .font(.callout) } } else { unavailableCurrentWindow @@ -638,10 +1117,13 @@ private struct GraphsWorkspace: View { } private var unavailableCurrentWindow: some View { - UnavailableGraph( + WorkspaceMessage( + icon: "chart.xyaxis.line", title: reader.evidence.reason ?? "Weekly usage unavailable", message: "Try refreshing to check again." - ) + ) { + EmptyView() + } } } @@ -1572,25 +2054,12 @@ func accountTokenIntervalText( timeZone: TimeZone = .autoupdatingCurrent, locale: Locale = .autoupdatingCurrent ) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.timeZone = timeZone - formatter.locale = locale - return "\(formatter.string(from: interval.start))–\(formatter.string(from: interval.end))" -} - -func accountTokenIntervalAccessibilityValue( - _ interval: AccountTokenActivityInterval, - timeZone: TimeZone = .autoupdatingCurrent, - locale: Locale = .autoupdatingCurrent -) -> String { - let dates = accountTokenIntervalText( - DateInterval(start: interval.start, end: interval.end), - timeZone: timeZone, - locale: locale - ) - return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName)." + Date.IntervalFormatStyle( + date: .abbreviated, + time: .shortened, + locale: locale, + timeZone: timeZone + ).format(interval.start ..< interval.end) } func accountTokenDisplayIntervalAccessibilityValue( @@ -1769,15 +2238,44 @@ private struct TokenActivityWorkspace: View { } private var accountCard: some View { - TokenSourceCard( - title: "Account", - source: reader.accountTokenActivity.sourceDescription, - value: reader.accountTokenActivity.tokens.map(compactTokenCount) - ?? "Not available", - detail: summaryDetail, - freshness: reader.fetchedAt, - freshnessLabel: "Updated", - color: .blue + let source = reader.accountTokenActivity.sourceDescription + let value = reader.accountTokenActivity.tokens.map(compactTokenCount) + ?? "Not available" + return VStack(alignment: .leading, spacing: 9) { + Label("Account", systemImage: "person.crop.circle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.blue) + Text(source) + .font(.caption2) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 26, weight: .semibold, design: .rounded)) + .monospacedDigit() + Text(summaryDetail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + if let freshness = reader.fetchedAt { + Text("Updated " + freshness.formatted( + date: .abbreviated, + time: .shortened + )) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) + .background( + Color.blue.opacity(0.07), + in: RoundedRectangle(cornerRadius: 12) + ) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue.opacity(0.16)) + } + .help( + "Account, \(source). \(value) tokens. \(summaryDetail)" ) } @@ -2019,57 +2517,6 @@ private struct TokenActivityWorkspace: View { } } -private struct TokenSourceCard: View { - let title: String - let source: String - let value: String - let detail: String - let freshness: Date? - let freshnessLabel: String - let color: Color - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - Label(title, systemImage: title == "Account" - ? "person.crop.circle" - : "laptopcomputer") - .font(.callout.weight(.semibold)) - .foregroundStyle(color) - Text(source) - .font(.caption2) - .foregroundStyle(.tertiary) - Text(value) - .font(.system(size: 26, weight: .semibold, design: .rounded)) - .monospacedDigit() - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - if let freshness { - Text(freshnessLabel + " " + freshness.formatted( - date: .abbreviated, - time: .shortened - )) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .padding(14) - .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) - .background( - color.opacity(0.07), - in: RoundedRectangle(cornerRadius: 12) - ) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.16)) - } - .help( - "\(title), \(source). \(value) tokens. \(detail)" - ) - } -} - private struct WorkspaceFilterMenu: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore @@ -2158,6 +2605,152 @@ private struct WorkspaceFilterMenu: View { } } +private struct UsageOverviewChart: View { + let snapshot: UsageOverviewSnapshot + + var body: some View { + Chart { + RuleMark(y: .value("Empty", 0)) + .foregroundStyle(Color.secondary.opacity(0.2)) + ForEach(snapshot.target) { point in + LineMark( + x: .value("Time", point.date), y: .value("Remaining", point.remaining), + series: .value("Series", "Target") + ) + .foregroundStyle(Color.green) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 3])) + } + ForEach(Array(snapshot.observedSegments.enumerated()), id: \.offset) { index, segment in + ForEach(segment) { point in + LineMark( + x: .value("Time", point.date), y: .value("Remaining", point.remaining), + series: .value("Series", "Actual \(index)") + ) + .foregroundStyle(Color.blue) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.stepEnd) + if segment.count == 1 { + PointMark(x: .value("Time", point.date), y: .value("Remaining", point.remaining)) + .foregroundStyle(Color.blue) + .symbolSize(8) + } + } + } + if let point = snapshot.latest { + PointMark(x: .value("Time", point.date), y: .value("Remaining", point.remaining)) + .foregroundStyle(Color.blue) + .symbolSize(28) + } + } + .chartXScale(domain: snapshot.range.start ... snapshot.range.end) + .chartYScale(domain: 0 ... 100) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + .padding(3) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Current usage window") + .accessibilityValue(snapshot.latest.map { + "Last recorded \(Int($0.remaining.rounded()))% remaining, \($0.date.formatted(date: .abbreviated, time: .shortened))" + } ?? "No usage observations") + .help("Current window · Blue: actual usage remaining · Green dashed: target") + } +} + +private struct IntegrationUsageRemainingView: View { + private struct Input: Equatable, Sendable { + let metric: String + let observations: [AllowanceObservation] + let current: AllowanceObservation? + let now: Date + let isStale: Bool + let safetyBuffer: Double + + func chart() -> IntegrationAllowanceChart? { + IntegrationAllowanceChart( + metric: metric, observations: observations, current: current, + now: now, isStale: isStale, safetyBuffer: safetyBuffer + ) + } + } + + let title: String + let metric: String + let observations: [AllowanceObservation] + let current: AllowanceObservation? + let now: Date + let isStale: Bool + @StateObject private var store: AnalyticsWorkspaceStore + @AppStorage private var safetyBuffer: Double + @State private var data: IntegrationAllowanceChart? + @State private var renderedInput: Input? + + init( + title: String, metric: String, observations: [AllowanceObservation], + current: AllowanceObservation?, now: Date, isStale: Bool, defaults: UserDefaults + ) { + self.title = title + self.metric = metric + self.observations = observations + self.current = current + self.now = now + self.isStale = isStale + _store = StateObject(wrappedValue: AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: metric + ".")) + _safetyBuffer = AppStorage(wrappedValue: 3, UsageMonitor.safetyBufferKey, store: defaults) + } + + var body: some View { + let input = Input( + metric: metric, observations: observations, current: current, + now: now, isStale: isStale, safetyBuffer: safetyBuffer + ) + return VStack(alignment: .leading, spacing: 0) { + if let data { + let chart = renderedInput == input ? data.chart : UsageChartSnapshot( + observedSource: data.chart.observedSource, + target: data.chart.target, + currentProjection: [], + currentAllowanceReset: data.chart.currentAllowanceReset, + allowanceWindows: data.chart.allowanceWindows, + currentRunsFaster: false, + accessibilityValue: "Recorded usage history. Updating the estimate." + ) + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(title).font(.headline) + Spacer() + Picker("Range", selection: Binding(get: { store.state.timeRange }, set: store.selectTimeRange)) { + ForEach(AnalyticsTimeRange.allCases.filter { $0.isPreset || store.state.timeRange == .selected }) { + Text($0.rawValue).tag($0) + } + } + .frame(maxWidth: 200) + .accessibilityLabel("\(title) time range") + } + UsageRemainingChart( + window: data.window, chart: chart, evidence: data.evidence, + store: store, now: now + ) + if let reason = data.forecastUnavailableReason { + Text(reason).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .task(id: input) { + let task = Task.detached(priority: .userInitiated) { input.chart() } + let result = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + guard !Task.isCancelled else { return } + data = result + renderedInput = input + } + } +} + private struct UsageRemainingChart: View { let window: UsageWindow let chart: UsageChartSnapshot @@ -2198,7 +2791,9 @@ private struct UsageRemainingChart: View { private var xAxisDates: [Date] { let step: TimeInterval - if visibleRange.duration <= 2 * 86_400 { + if visibleRange.duration <= 6 * 3_600 { + step = 3_600 + } else if visibleRange.duration <= 2 * 86_400 { step = 6 * 3_600 } else if visibleRange.duration <= 10 * 86_400 { step = 86_400 @@ -2240,7 +2835,8 @@ private struct UsageRemainingChart: View { AxisValueLabel { if let date = value.as(Date.self) { if visibleRange.duration <= 2 * 86_400 { - Text(date, format: .dateTime.hour()) + Text(date, format: .dateTime.hour().minute()) + .fixedSize() } else if visibleRange.duration <= 10 * 86_400 { Text( date, @@ -2299,6 +2895,13 @@ private struct UsageRemainingChart: View { } keyboardRangeStart = nil } + .onChange(of: chart) { _, chart in + if let selection { + self.selection = UsageChartSelection.nearest( + to: selection.date, in: chart, within: visibleRange + ) + } + } selectedPointDetail selectedRangeDetail @@ -2359,7 +2962,9 @@ private struct UsageRemainingChart: View { @ViewBuilder private var chartLegend: some View { - ChartLegendItem(label: "Target", color: .green, dash: [3, 3]) + if !chart.target.isEmpty { + ChartLegendItem(label: "Target", color: .green, dash: [3, 3]) + } ChartLegendItem( label: "Actual · \(chart.observedSource.rawValue)", color: .blue @@ -2850,6 +3455,14 @@ private struct FactsWorkspace: View { } } + WorkspaceCard(title: "Active Time") { + activeTimeContent + } + + WorkspaceCard(title: "Usage Receipts") { + receiptContent + } + } } @@ -4469,21 +5082,6 @@ private struct FactRow: View { } } -private struct UnavailableGraph: View { - let title: String - let message: String - - var body: some View { - WorkspaceMessage( - icon: "chart.xyaxis.line", - title: title, - message: message - ) { - EmptyView() - } - } -} - private struct StaleDataNotice: View { let message: String diff --git a/Sources/CodexLimits/ResetReminder.swift b/Sources/CodexLimits/ResetReminder.swift index f87f594..c3ab552 100644 --- a/Sources/CodexLimits/ResetReminder.swift +++ b/Sources/CodexLimits/ResetReminder.swift @@ -34,14 +34,13 @@ enum ResetReminderDelivery: Equatable { case permissionRequired case permissionDenied case scheduled(Date) - case reminderTimePassed(Date) + case reminderTimePassed case failed } struct ResetReminderState: Equatable { let isEnabled: Bool let leadTime: ResetReminderLeadTime - let authorization: ResetReminderAuthorization let delivery: ResetReminderDelivery var statusText: String { @@ -77,9 +76,7 @@ struct ResetReminderTarget: Equatable, Sendable { } struct ResetReminderRequest: Equatable, Sendable { - let resetID: String let firesAt: Date - let expiresAt: Date let title: String let body: String } @@ -125,7 +122,6 @@ final class ResetReminderCoordinator { state = ResetReminderState( isEnabled: isEnabled, leadTime: leadTime, - authorization: .unknown, delivery: isEnabled ? scheduledRecord.map { .scheduled($0.firesAt) } ?? .waitingForExpiry : .off @@ -208,23 +204,18 @@ final class ResetReminderCoordinator { guard await continueIfCurrent(version) else { return } if authorization == .notDetermined || authorization == .unknown { guard mayRequestPermission else { - update( - authorization: authorization, - delivery: .permissionRequired - ) + update(delivery: .permissionRequired) return } do { authorization = try await scheduler.requestAuthorization() } catch { guard await continueIfCurrent(version) else { return } - update(authorization: authorization, delivery: .failed) + update(delivery: .failed) return } guard await continueIfCurrent(version) else { return } } - update(authorization: authorization) - guard authorization == .authorized else { await scheduler.cancel() guard await continueIfCurrent(version) else { return } @@ -259,11 +250,7 @@ final class ResetReminderCoordinator { return } } - update( - delivery: .reminderTimePassed( - existingRecord.firesAt - ) - ) + update(delivery: .reminderTimePassed) return } @@ -286,9 +273,7 @@ final class ResetReminderCoordinator { ? state.leadTime.displayName : Self.shortDuration(target.expiresAt.timeIntervalSince(currentTime)) let request = ResetReminderRequest( - resetID: target.id, firesAt: firesAt, - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in \(bodyTime)." ) @@ -324,13 +309,11 @@ final class ResetReminderCoordinator { private func update( isEnabled: Bool? = nil, leadTime: ResetReminderLeadTime? = nil, - authorization: ResetReminderAuthorization? = nil, delivery: ResetReminderDelivery? = nil ) { state = ResetReminderState( isEnabled: isEnabled ?? state.isEnabled, leadTime: leadTime ?? state.leadTime, - authorization: authorization ?? state.authorization, delivery: delivery ?? state.delivery ) } diff --git a/Sources/CodexLimits/SettingsView.swift b/Sources/CodexLimits/SettingsView.swift index e4f05ee..f6ce279 100644 --- a/Sources/CodexLimits/SettingsView.swift +++ b/Sources/CodexLimits/SettingsView.swift @@ -4,13 +4,148 @@ import SwiftUI struct SettingsView: View { @ObservedObject var monitor: UsageMonitor + @ObservedObject var integrations: IntegrationPreferences + @ObservedObject var claudeCode: ClaudeCodeIntegrationStore + @ObservedObject var grok: GrokIntegrationStore @AppStorage(UsageMonitor.safetyBufferKey) private var safetyBuffer = 3.0 @AppStorage(LoginItem.preferenceKey) private var launchAtLogin = true @State private var loginItemError: String? @State private var confirmsHistoryDeletion = false + @State private var confirmsClaudeSetup = false + @State private var confirmsClaudeDataDeletion = false + @State private var codexExecutableError: String? + @State private var claudeExecutableError: String? + @State private var grokExecutableError: String? + @State private var confirmsGrokDataDeletion = false var body: some View { Form { + Section("Integrations") { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Codex") + if integrations.isEnabled(.codex) { + Text(codexReadiness) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle( + "Codex", + isOn: Binding( + get: { integrations.isEnabled(.codex) }, + set: setCodexEnabled + ) + ) + .labelsHidden() + } + + if integrations.isEnabled(.codex), !monitor.isRefreshing { + if codexNotFound { + Button("Locate…", action: locateCodexExecutable) + } + if monitor.readerSnapshot.account == nil { + Button("Check again") { + Task { await monitor.refresh() } + } + } + } + if let codexExecutableError { + Text(codexExecutableError) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Claude Code") + Text("Experimental") + .font(.caption2) + .foregroundStyle(.secondary) + } + if integrations.isEnabled(.claudeCode) + || claudeCode.readiness == .manualCleanupRequired { + Text(claudeReadiness) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle( + "Claude Code", + isOn: Binding( + get: { + integrations.isEnabled(.claudeCode) + }, + set: setClaudeEnabled + ) + ) + .labelsHidden() + } + + claudeActions + + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("Grok") + Text("Beta") + .font(.caption2) + .foregroundStyle(.secondary) + } + if integrations.isEnabled(.grok) { + Text(grok.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Toggle("Grok", isOn: Binding( + get: { integrations.isEnabled(.grok) }, + set: setGrokEnabled + )) + .labelsHidden() + } + if integrations.isEnabled(.grok) { + HStack { + Button("Check again") { Task { await grok.refresh() } } + .disabled(!grok.canRefresh) + Button("Locate…", action: locateGrokExecutable) + .disabled(grok.isRefreshing) + } + if grok.error == .notFound || grok.error == .unsupported { + Link("Install or update Grok Build", destination: URL(string: "https://x.ai/cli")!) + } + } + if let grokExecutableError { + Text(grokExecutableError).font(.caption).foregroundStyle(.secondary) + } + if let issue = grok.storageIssue { + Text(issue).font(.caption).foregroundStyle(.secondary) + } + if grok.hasStoredData || integrations.grokExecutablePath != nil { + Button("Delete Grok data…", role: .destructive) { + confirmsGrokDataDeletion = true + } + } + } + + Section("Menu bar") { + Picker( + "Metric", + selection: Binding( + get: { integrations.menuBarMetric }, + set: selectMenuBarMetric + ) + ) { + ForEach(integrations.availableMenuBarMetrics) { metric in + Text(metric.displayName).tag(metric) + } + } + } + Stepper( value: Binding( get: { SafetyBufferPolicy.normalized(safetyBuffer) }, @@ -112,7 +247,50 @@ struct SettingsView: View { } .formStyle(.grouped) .padding() - .frame(width: 380) + .frame(width: 420) + .task { + await claudeCode.settingsPresented() + guard !Task.isCancelled else { return } + await grok.settingsPresented() + } + .onDisappear { grok.settingsDismissed() } + .alert("Delete Grok integration data?", isPresented: $confirmsGrokDataDeletion) { + Button("Cancel", role: .cancel) {} + Button("Delete integration data", role: .destructive) { + integrations.setEnabled(false, for: .grok) + integrations.selectGrokExecutable(nil) + grokExecutableError = nil + Task { await grok.deleteData() } + } + } message: { + Text("This disables Grok and deletes its usage history saved by Codex Limits. It doesn’t delete Grok Build data or change your subscription.") + } + .alert("Set up Claude Code?", isPresented: $confirmsClaudeSetup) { + Button("Cancel", role: .cancel) {} + Button("Set up") { + Task { await claudeCode.setUp() } + } + } message: { + Text( + "This adds a Codex Limits command to Claude Code’s user status line. Claude Code will run it during activity, and its footer will show usage remaining. Project or managed settings can override it. Usage data is available on eligible Pro and Max accounts." + ) + } + .alert( + "Delete Claude Code integration data?", + isPresented: $confirmsClaudeDataDeletion + ) { + Button("Cancel", role: .cancel) {} + Button("Delete integration data", role: .destructive) { + integrations.setEnabled(false, for: .claudeCode) + integrations.selectClaudeExecutable(nil) + claudeExecutableError = nil + Task { await claudeCode.deleteData() } + } + } message: { + Text( + "This disables Claude Code and deletes its usage history and setup records saved by Codex Limits. It doesn’t delete Claude Code data." + ) + } .alert("Delete analytics history?", isPresented: $confirmsHistoryDeletion) { Button("Cancel", role: .cancel) {} Button("Delete analytics history", role: .destructive) { @@ -125,6 +303,179 @@ struct SettingsView: View { } } + private var codexReadiness: String { + if monitor.isRefreshing { return "Checking" } + if let sourceMessage = monitor.readerSnapshot.sourceMessage { + return sourceMessage + } + return monitor.readerSnapshot.account == nil ? "Set up" : "Ready" + } + + private var codexNotFound: Bool { + monitor.readerSnapshot.sourceMessage + == CodexClientError.cliNotFound.localizedDescription + } + + private var claudeReadiness: String { + switch claudeCode.readiness { + case .disabled: + "" + case .checking: + "Checking" + case .notFound: + "Not found" + case .setUp: + "Set up" + case .waitingForData: + "Waiting for data" + case .ready: + "Ready" + case .conflict: + "Existing status line" + case .updateRequired: + "Update required" + case .manualCleanupRequired: + "Remove the Codex Limits command from your Claude Code status line." + case .failed: + "Claude Code usage couldn’t be read." + } + } + + @ViewBuilder + private var claudeActions: some View { + if integrations.isEnabled(.claudeCode) { + switch claudeCode.readiness { + case .notFound: + Button("Locate…", action: locateClaudeExecutable) + Link( + "Install Claude Code", + destination: URL( + string: "https://code.claude.com/docs/en/setup" + )! + ) + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .setUp: + Button("Set up…") { + confirmsClaudeSetup = true + } + case .waitingForData: + Text("Usage data is available on eligible Pro and Max accounts.") + .font(.caption) + .foregroundStyle(.secondary) + Text("It appears after the first response in a session.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + case .ready: + if let observedAt = claudeCode.snapshot?.observedAt { + Text("Last observed \(observedAt.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Check for new observation") { + Task { await claudeCode.checkForNewObservation() } + } + case .conflict: + Text("Codex Limits won’t change your existing status line.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .updateRequired: + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .failed: + Button("Check again") { + Task { await claudeCode.settingsPresented() } + } + case .disabled, .checking, .manualCleanupRequired: + EmptyView() + } + } + if let claudeExecutableError { + Text(claudeExecutableError) + .font(.caption) + .foregroundStyle(.secondary) + } + if claudeCode.hasStoredData + || integrations.claudeExecutablePath != nil { + Button("Delete Claude Code data…", role: .destructive) { + confirmsClaudeDataDeletion = true + } + } + } + + private func setCodexEnabled(_ enabled: Bool) { + if !enabled { codexExecutableError = nil } + integrations.setEnabled(enabled, for: .codex) + let codexSelected = integrations.menuBarMetric + == .codexWeeklyUsageRemaining + Task { + await monitor.setMenuBarSourceActive(codexSelected) + await monitor.setEnabled(enabled) + } + } + + private func setClaudeEnabled(_ enabled: Bool) { + if !enabled { claudeExecutableError = nil } + integrations.setEnabled(enabled, for: .claudeCode) + let claudeSelected = integrations.menuBarMetric + == .claudeSevenDayUsageRemaining + Task { + await claudeCode.setMenuBarSourceActive(claudeSelected) + await claudeCode.setEnabled(enabled) + } + } + + private func selectMenuBarMetric(_ metric: MenuBarMetric) { + integrations.selectMenuBarMetric(metric) + let codexSelected = integrations.menuBarMetric + == .codexWeeklyUsageRemaining + let claudeSelected = integrations.menuBarMetric + == .claudeSevenDayUsageRemaining + let grokSelected = integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining + Task { + // Stop the previous source before a selected source can await a read. + if !codexSelected { await monitor.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if !claudeSelected { await claudeCode.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if !grokSelected { await grok.setMenuBarSourceActive(false) } + guard integrations.menuBarMetric == metric else { return } + if codexSelected { await monitor.setMenuBarSourceActive(true) } + if claudeSelected { await claudeCode.setMenuBarSourceActive(true) } + if grokSelected { await grok.setMenuBarSourceActive(true) } + } + } + + private func setGrokEnabled(_ enabled: Bool) { + if !enabled { grokExecutableError = nil } + integrations.setEnabled(enabled, for: .grok) + let selected = integrations.menuBarMetric == .grokCurrentPeriodUsageRemaining + Task { + await grok.setMenuBarSourceActive(selected) + await grok.setEnabled(enabled) + } + } + + private func locateGrokExecutable() { + guard let url = chooseExecutable(message: "Choose the Grok Build executable.") else { return } + Task { + if await grok.selectExecutable(url) { + integrations.selectGrokExecutable(url) + grokExecutableError = nil + } else { + grokExecutableError = "Choose a regular executable file." + } + } + } + private func updateLaunchAtLogin(_ enabled: Bool) { do { if enabled, SMAppService.mainApp.status != .enabled { @@ -150,6 +501,44 @@ struct SettingsView: View { guard panel.runModal() == .OK, let directory = panel.url else { return } Task { await monitor.connectHistoryFolder(directory) } } + + private func locateClaudeExecutable() { + guard let url = chooseExecutable( + message: "Choose the Claude Code executable." + ) else { return } + Task { + if await claudeCode.selectExecutable(url) { + integrations.selectClaudeExecutable(url) + claudeExecutableError = nil + } else { + claudeExecutableError = "Choose a regular executable file." + } + } + } + + private func locateCodexExecutable() { + guard let url = chooseExecutable( + message: "Choose the Codex executable." + ) else { return } + guard CodexClient.selectExecutable(url) else { + codexExecutableError = "Choose a regular executable file." + return + } + integrations.selectCodexExecutable(url) + codexExecutableError = nil + Task { await monitor.refresh() } + } + + private func chooseExecutable(message: String) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.message = message + guard panel.runModal() == .OK else { return nil } + return panel.url + } } enum LoginItem { diff --git a/Sources/CodexLimits/UsageHistory.swift b/Sources/CodexLimits/UsageHistory.swift index da35ec5..0a0fedd 100644 --- a/Sources/CodexLimits/UsageHistory.swift +++ b/Sources/CodexLimits/UsageHistory.swift @@ -82,6 +82,19 @@ actor UsageHistory { let issue: Issue? } + enum RangeResolution: Equatable, Sendable { + case exact + case downsampled + } + + struct RangeView: Equatable, Sendable { + let retainedBounds: DateInterval? + let coveredInterval: DateInterval? + let samples: [UsageSample] + let resolution: RangeResolution + let hadReadError: Bool + } + private struct Marker: Codable { let version: Int let generation: Int? @@ -123,6 +136,29 @@ actor UsageHistory { let digest: String } + private struct WriterManifest: Codable { + let version: Int + let generation: Int + let oldestDay: String + let newestDay: String + let latestChangedDay: String + let revision: UInt64 + } + + private struct WriterCursor: Codable { + var nextDay: String? + var observedRevision: UInt64? + } + + private struct SyncProgress: Codable { + let version: Int + let lineageID: String + let generation: Int + var publication: WriterCursor + var imports: [String: WriterCursor] + var nextImportWriter: String? + } + private enum HistoryError: Error { case invalidFolder case invalidFile @@ -140,9 +176,50 @@ actor UsageHistory { private static let lineagesName = "lineages" private static let deletionsName = "deletions" private static let accountBindingName = ".codex-limits-account.json" + private static let writerManifestName = ".codex-limits-writer" + private static let syncProgressName = ".codex-limits-sync-progress" private static let maximumFileSize = 1_000_000 private static let maximumGeneration = 1_000_000_000 + private static let maximumAutomaticSyncCandidates = 32 private static let automaticSyncInterval: TimeInterval = 30 * 60 + private static let localMarkerUpdateLock = NSLock() + private static let workingSetFileDays = 90 + private static let workingSetDuration: TimeInterval = 84 * 86_400 + private static let fullResolutionDuration: TimeInterval = 8 * 86_400 + private static let historicalBucketDuration: TimeInterval = 60 * 60 + private static let maximumWorkingSetSamples = 6_000 + private static let maximumWorkingSetWriters = 32 + private static let maximumWorkingSetFileReads = 256 + private static let maximumWorkingSetReadBytes = 8 * 1_024 * 1_024 + + private struct WorkingSetReadBudget { + var files = 0 + var bytes = 0 + var didReachLimit = false + + mutating func admit(_ url: URL) throws -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { + return false + } + let values = try url.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + guard values.isRegularFile == true, + let size = values.fileSize, + size >= 0, + size <= UsageHistory.maximumFileSize else { + throw HistoryError.invalidFile + } + guard files < UsageHistory.maximumWorkingSetFileReads, + size <= UsageHistory.maximumWorkingSetReadBytes - bytes else { + didReachLimit = true + return false + } + files += 1 + bytes += size + return true + } + } private let localDirectory: URL private let installationID: String @@ -204,7 +281,7 @@ actor UsageHistory { } else if marker.pendingDeletionTarget == .localOnly { deletionStatus = .complete } - let local = readAll(from: activeLocalDirectory) + let local = readWorkingSet(from: activeLocalDirectory) knownSamples = local.samples errorMessage = if migrationWarning { "Some usage history couldn’t be migrated." @@ -241,7 +318,7 @@ actor UsageHistory { installationID: installationID, coordinated: false ) - knownSamples = normalized(knownSamples + [sample]) + knownSamples = boundedWorkingSet(knownSamples + [sample]) if let syncDirectory { try prepareRoot( syncDirectory, @@ -272,7 +349,8 @@ actor UsageHistory { to directory: URL, accountIdentity: String? = nil, accountBindingToken: String? = nil, - bindsUnresolvedDeletionTarget: Bool = false + bindsUnresolvedDeletionTarget: Bool = false, + performFullReconciliation: Bool = true ) -> State { do { try prepareLocalStore() @@ -329,7 +407,11 @@ actor UsageHistory { try reconcileGeneration(with: directory) syncDirectory = directory errorMessage = nil - return synchronize() + return synchronize( + at: Date(), + refreshLocalWhenDisconnected: false, + boundedReconciliation: !performFullReconciliation + ) } catch { if case HistoryError.wrongDeletionFolder = error { syncDirectory = nil @@ -378,7 +460,11 @@ actor UsageHistory { } func synchronize() -> State { - synchronize(at: Date()) + synchronize( + at: Date(), + refreshLocalWhenDisconnected: true, + boundedReconciliation: true + ) } func synchronizeIfDue(at now: Date = Date()) -> State { @@ -388,12 +474,114 @@ actor UsageHistory { return state() } } - return synchronize(at: now) + return synchronize( + at: now, + refreshLocalWhenDisconnected: false, + boundedReconciliation: true + ) } - private func synchronize(at now: Date) -> State { + func rangeView(for requestedInterval: DateInterval) -> RangeView? { + guard requestedInterval.duration > 0, + requestedInterval.duration <= Self.workingSetDuration, + requestedInterval.start.timeIntervalSinceReferenceDate.isFinite, + requestedInterval.end.timeIntervalSinceReferenceDate.isFinite else { + return nil + } + do { + try prepareLocalStore() + try prepareRoot( + activeLocalDirectory, + createIfMissing: true, + coordinated: false + ) + } catch { + return RangeView( + retainedBounds: nil, + coveredInterval: nil, + samples: [], + resolution: .exact, + hadReadError: true + ) + } + + let directory = installationsDirectory(in: activeLocalDirectory) + var retainedStart: Date? + var retainedEnd: Date? + var samples: [UsageSample] = [] + var wasDownsampled = false + var hadReadError = false + var budget = WorkingSetReadBudget() + do { + let writers = try boundedWorkingSetWriters(in: directory) + hadReadError = writers.didReachLimit + writerLoop: for writer in writers.values { + guard !Task.isCancelled else { return nil } + do { + if let manifest = try ensureWriterManifest( + in: writer, + generation: 1, + coordinated: false + ), let oldest = date(forDayName: manifest.oldestDay), + let newest = nextDay(after: manifest.newestDay) + .flatMap(date(forDayName:)) { + retainedStart = min(retainedStart ?? oldest, oldest) + retainedEnd = max(retainedEnd ?? newest, newest) + } + } catch { + hadReadError = true + } + + for day in dayNames(in: requestedInterval) { + guard !Task.isCancelled else { return nil } + let file = writer.appendingPathComponent("\(day).json") + do { + guard try budget.admit(file) else { + if budget.didReachLimit { + hadReadError = true + break writerLoop + } + continue + } + let next = try readDailyFileIfPresent( + at: file, + coordinated: false + ).filter { requestedInterval.contains($0.observedAt) } + let bounded = boundedRangeSamples(samples + next) + samples = bounded.samples + wasDownsampled = wasDownsampled || bounded.didDownsample + } catch { + hadReadError = true + } + } + } + } catch { + hadReadError = true + } + + let retainedBounds = retainedStart.flatMap { start in + retainedEnd.flatMap { end in + end > start ? DateInterval(start: start, end: end) : nil + } + } + return RangeView( + retainedBounds: retainedBounds, + coveredInterval: retainedBounds?.intersection(with: requestedInterval), + samples: samples, + resolution: wasDownsampled ? .downsampled : .exact, + hadReadError: hadReadError + ) + } + + private func synchronize( + at now: Date, + refreshLocalWhenDisconnected: Bool, + boundedReconciliation: Bool + ) -> State { lastSynchronizationAttemptAt = now - guard let syncDirectory else { return state(refreshSamples: true) } + guard let syncDirectory else { + return state(refreshSamples: refreshLocalWhenDisconnected) + } do { try prepareLocalStore() try prepareRoot( @@ -416,18 +604,37 @@ actor UsageHistory { } try reconcileGeneration(with: syncDirectory) let generation = try effectiveGeneration(in: syncDirectory) - let hadImportErrors = try importHistory( - from: syncDirectory, - generation: generation - ) - try publishOwnHistory(to: syncDirectory, generation: generation) + let hadImportErrors: Bool + if boundedReconciliation { + let marker = try readMarker( + at: localDirectory.appendingPathComponent(Self.markerName), + coordinated: false + ) + guard let lineageID = marker.syncTarget else { + throw HistoryError.invalidFolder + } + hadImportErrors = try synchronizeBounded( + with: syncDirectory, + generation: generation, + lineageID: lineageID + ) + } else { + hadImportErrors = try importHistory( + from: syncDirectory, + generation: generation + ) + try publishOwnHistory( + to: syncDirectory, + generation: generation + ) + } errorMessage = hadImportErrors ? "Some synced history couldn’t be read." : nil } catch { errorMessage = message(for: error) } - return state(refreshSamples: true) + return state() } func deleteAnalyticsHistory( @@ -621,15 +828,15 @@ actor UsageHistory { refreshSamples: Bool = false ) -> State { if refreshSamples { - let local = readAll(from: activeLocalDirectory) + let local = readWorkingSet(from: activeLocalDirectory) if local.hadError && errorMessage == nil { errorMessage = "Some usage history couldn’t be read." } knownSamples = local.hadError || errorMessage != nil - ? normalized(local.samples + knownSamples + fallback) + ? boundedWorkingSet(local.samples + knownSamples + fallback) : local.samples } else if !fallback.isEmpty { - knownSamples = normalized(knownSamples + fallback) + knownSamples = boundedWorkingSet(knownSamples + fallback) } return State( samples: knownSamples, @@ -718,23 +925,215 @@ actor UsageHistory { .appendingPathComponent(installationID, isDirectory: true) try createDirectory(at: writerDirectory, coordinated: coordinated) - for (day, newSamples) in grouped { + for day in grouped.keys.sorted() { + guard let newSamples = grouped[day] else { continue } let url = writerDirectory.appendingPathComponent("\(day).json") let existing = try readDailyFileIfPresent( at: url, generation: generation, coordinated: coordinated ) + let merged = normalized(existing + newSamples) + guard merged != existing else { continue } try write( - normalized(existing + newSamples), + merged, to: url, generation: generation, coordinated: coordinated ) + try noteChangedDay( + day, + in: writerDirectory, + generation: generation ?? 1, + coordinated: coordinated + ) } } - private func importHistory(from remoteRoot: URL, generation: Int) throws -> Bool { + private func noteChangedDay( + _ day: String, + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws { + guard date(forDayName: day) != nil else { + throw HistoryError.invalidFile + } + let url = writerDirectory.appendingPathComponent( + Self.writerManifestName + ) + let existing: WriterManifest? = if FileManager.default.fileExists( + atPath: url.path + ) { + try JSONDecoder().decode( + WriterManifest.self, + from: readData(at: url, coordinated: coordinated) + ) + } else { + nil + } + if let existing { + guard existing.version == 1, + existing.generation == generation, + date(forDayName: existing.oldestDay) != nil, + date(forDayName: existing.newestDay) != nil, + date(forDayName: existing.latestChangedDay) != nil, + existing.revision < UInt64.max else { + throw HistoryError.invalidFile + } + } + let manifest = WriterManifest( + version: 1, + generation: generation, + oldestDay: min(existing?.oldestDay ?? day, day), + newestDay: max(existing?.newestDay ?? day, day), + latestChangedDay: day, + revision: (existing?.revision ?? 0) + 1 + ) + try writeData( + try JSONEncoder().encode(manifest), + to: url, + coordinated: coordinated + ) + } + + private func writerManifest( + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws -> WriterManifest? { + let url = writerDirectory.appendingPathComponent( + Self.writerManifestName + ) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let manifest = try JSONDecoder().decode( + WriterManifest.self, + from: readData(at: url, coordinated: coordinated) + ) + guard manifest.version == 1, + manifest.generation == generation, + date(forDayName: manifest.oldestDay) != nil, + date(forDayName: manifest.newestDay) != nil, + date(forDayName: manifest.latestChangedDay) != nil, + manifest.oldestDay <= manifest.newestDay else { + throw HistoryError.invalidFile + } + return manifest + } + + private func ensureWriterManifest( + in writerDirectory: URL, + generation: Int, + coordinated: Bool + ) throws -> WriterManifest? { + if let manifest = try writerManifest( + in: writerDirectory, + generation: generation, + coordinated: coordinated + ) { + return manifest + } + let days = try jsonFiles(in: writerDirectory) + .map { $0.deletingPathExtension().lastPathComponent } + .filter { date(forDayName: $0) != nil } + .sorted() + guard let oldestDay = days.first, + let newestDay = days.last else { + return nil + } + let manifest = WriterManifest( + version: 1, + generation: generation, + oldestDay: oldestDay, + newestDay: newestDay, + latestChangedDay: newestDay, + revision: 1 + ) + try writeData( + try JSONEncoder().encode(manifest), + to: writerDirectory.appendingPathComponent( + Self.writerManifestName + ), + coordinated: coordinated + ) + return manifest + } + + private func syncProgress( + lineageID: String, + generation: Int + ) -> SyncProgress { + let url = activeLocalDirectory.appendingPathComponent( + Self.syncProgressName + ) + if let data = try? readData(at: url, coordinated: false), + let progress = try? JSONDecoder().decode( + SyncProgress.self, + from: data + ), progress.version == 1, + progress.lineageID == lineageID, + progress.generation == generation { + return progress + } + return SyncProgress( + version: 1, + lineageID: lineageID, + generation: generation, + publication: WriterCursor( + nextDay: nil, + observedRevision: nil + ), + imports: [:], + nextImportWriter: nil + ) + } + + private func saveSyncProgress(_ progress: SyncProgress) throws { + try writeData( + try JSONEncoder().encode(progress), + to: activeLocalDirectory.appendingPathComponent( + Self.syncProgressName + ), + coordinated: false + ) + } + + private func automaticCandidates( + manifest: WriterManifest, + cursor: inout WriterCursor, + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + var result: [String] = [] + if cursor.observedRevision != manifest.revision { + result.append(manifest.latestChangedDay) + cursor.observedRevision = manifest.revision + } + var next = cursor.nextDay.flatMap { day in + day >= manifest.oldestDay && day <= manifest.newestDay + ? day + : nil + } ?? manifest.oldestDay + var visited: Set = [] + while result.count < limit, visited.insert(next).inserted { + if !result.contains(next) { + result.append(next) + } + guard let following = nextDay(after: next) else { break } + next = following > manifest.newestDay + ? manifest.oldestDay + : following + cursor.nextDay = next + } + return result + } + + private func importHistory( + from remoteRoot: URL, + generation: Int + ) throws -> Bool { let remoteInstallations = installationsDirectory( in: remoteRoot, generation: generation @@ -747,7 +1146,9 @@ actor UsageHistory { isDirectory: true ) try createDirectory(at: localWriter, coordinated: false) - for remoteFile in try jsonFiles(in: remoteWriter) { + for remoteFile in try jsonFiles(in: remoteWriter).sorted(by: { + $0.lastPathComponent < $1.lastPathComponent + }) { do { let localFile = localWriter.appendingPathComponent(remoteFile.lastPathComponent) let remoteSamples = try readDailyFileIfPresent( @@ -756,11 +1157,21 @@ actor UsageHistory { coordinated: true ) let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) - try write( - normalized(localSamples + remoteSamples), - to: localFile, - coordinated: false - ) + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { + try write( + merged, + to: localFile, + coordinated: false + ) + try noteChangedDay( + remoteFile.deletingPathExtension().lastPathComponent, + in: localWriter, + generation: 1, + coordinated: false + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) } catch { hadError = true } @@ -769,7 +1180,10 @@ actor UsageHistory { return hadError } - private func publishOwnHistory(to remoteRoot: URL, generation: Int) throws { + private func publishOwnHistory( + to remoteRoot: URL, + generation: Int + ) throws { let localWriter = installationsDirectory(in: activeLocalDirectory) .appendingPathComponent(installationID, isDirectory: true) guard FileManager.default.fileExists(atPath: localWriter.path) else { return } @@ -779,7 +1193,9 @@ actor UsageHistory { ) .appendingPathComponent(installationID, isDirectory: true) try createDirectory(at: remoteWriter, coordinated: true) - for localFile in try jsonFiles(in: localWriter) { + for localFile in try jsonFiles(in: localWriter).sorted(by: { + $0.lastPathComponent < $1.lastPathComponent + }) { let remoteFile = remoteWriter.appendingPathComponent(localFile.lastPathComponent) let localSamples = try readDailyFileIfPresent(at: localFile, coordinated: false) let remoteSamples = try readDailyFileIfPresent( @@ -788,13 +1204,250 @@ actor UsageHistory { coordinated: true ) let merged = normalized(localSamples + remoteSamples) + let day = localFile.deletingPathExtension().lastPathComponent + if merged != localSamples { + try write(merged, to: localFile, coordinated: false) + try noteChangedDay( + day, + in: localWriter, + generation: 1, + coordinated: false + ) + } + if merged != remoteSamples { + try write( + merged, + to: remoteFile, + generation: generation, + coordinated: true + ) + try noteChangedDay( + day, + in: remoteWriter, + generation: generation, + coordinated: true + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + } + } + + private func synchronizeBounded( + with remoteRoot: URL, + generation: Int, + lineageID: String + ) throws -> Bool { + var progress = syncProgress( + lineageID: lineageID, + generation: generation + ) + var remaining = Self.maximumAutomaticSyncCandidates + var hadError = false + let remoteInstallations = installationsDirectory( + in: remoteRoot, + generation: generation + ) + let remoteWriters = try directoryContents(of: remoteInstallations) + .filter { $0.lastPathComponent != installationID } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + let localWriter = installationsDirectory(in: activeLocalDirectory) + .appendingPathComponent(installationID, isDirectory: true) + if FileManager.default.fileExists(atPath: localWriter.path), + let manifest = try ensureWriterManifest( + in: localWriter, + generation: 1, + coordinated: false + ) { + var cursor = progress.publication + let limit = remoteWriters.isEmpty + ? remaining + : min(8, remaining) + let days = automaticCandidates( + manifest: manifest, + cursor: &cursor, + limit: limit + ) + let remoteWriter = remoteInstallations.appendingPathComponent( + installationID, + isDirectory: true + ) + try createDirectory(at: remoteWriter, coordinated: true) + for day in days { + hadError = try publishDay( + day, + from: localWriter, + to: remoteWriter, + generation: generation + ) || hadError + } + progress.publication = cursor + remaining -= days.count + } + + let orderedWriters = rotatedWriters( + remoteWriters, + startingAt: progress.nextImportWriter + ) + var lastWriterID: String? + for (index, remoteWriter) in orderedWriters.enumerated() + where remaining > 0 { + let writerID = remoteWriter.lastPathComponent + let writersLeft = orderedWriters.count - index + let limit = max(remaining / max(writersLeft, 1), 1) + guard let manifest = try ensureWriterManifest( + in: remoteWriter, + generation: generation, + coordinated: true + ) else { + lastWriterID = writerID + continue + } + var cursor = progress.imports[writerID] ?? WriterCursor( + nextDay: nil, + observedRevision: nil + ) + let days = automaticCandidates( + manifest: manifest, + cursor: &cursor, + limit: min(limit, remaining) + ) + let localWriter = installationsDirectory(in: activeLocalDirectory) + .appendingPathComponent(writerID, isDirectory: true) + try createDirectory(at: localWriter, coordinated: false) + for day in days { + hadError = try importDay( + day, + from: remoteWriter, + to: localWriter, + generation: generation + ) || hadError + } + progress.imports[writerID] = cursor + remaining -= days.count + lastWriterID = writerID + } + if let lastWriterID, + let index = remoteWriters.firstIndex(where: { + $0.lastPathComponent == lastWriterID + }), !remoteWriters.isEmpty { + progress.nextImportWriter = remoteWriters[ + (index + 1) % remoteWriters.count + ].lastPathComponent + } + try saveSyncProgress(progress) + return hadError + } + + private func rotatedWriters( + _ writers: [URL], + startingAt writerID: String? + ) -> [URL] { + guard let writerID, + let index = writers.firstIndex(where: { + $0.lastPathComponent >= writerID + }), index > 0 else { + return writers + } + return Array(writers[index...]) + Array(writers[.. Bool { + let remoteFile = remoteWriter.appendingPathComponent("\(day).json") + guard FileManager.default.fileExists(atPath: remoteFile.path) else { + return false + } + let localFile = localWriter.appendingPathComponent("\(day).json") + let localSamples: [UsageSample] + let remoteSamples: [UsageSample] + do { + localSamples = try readDailyFileIfPresent( + at: localFile, + coordinated: false + ) + remoteSamples = try readDailyFileIfPresent( + at: remoteFile, + generation: generation, + coordinated: true + ) + } catch { + guard isMalformedHistoryFileError(error) else { throw error } + return true + } + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { try write(merged, to: localFile, coordinated: false) + try noteChangedDay( + day, + in: localWriter, + generation: 1, + coordinated: false + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + return false + } + + private func publishDay( + _ day: String, + from localWriter: URL, + to remoteWriter: URL, + generation: Int + ) throws -> Bool { + let localFile = localWriter.appendingPathComponent("\(day).json") + guard FileManager.default.fileExists(atPath: localFile.path) else { + return false + } + let remoteFile = remoteWriter.appendingPathComponent("\(day).json") + let localSamples: [UsageSample] + let remoteSamples: [UsageSample] + do { + localSamples = try readDailyFileIfPresent( + at: localFile, + coordinated: false + ) + remoteSamples = try readDailyFileIfPresent( + at: remoteFile, + generation: generation, + coordinated: true + ) + } catch { + guard isMalformedHistoryFileError(error) else { throw error } + return true + } + let merged = normalized(localSamples + remoteSamples) + if merged != localSamples { + try write(merged, to: localFile, coordinated: false) + } + if merged != remoteSamples { try write( merged, to: remoteFile, generation: generation, coordinated: true ) + try noteChangedDay( + day, + in: remoteWriter, + generation: generation, + coordinated: true + ) + } + knownSamples = boundedWorkingSet(knownSamples + merged) + return false + } + + private func isMalformedHistoryFileError(_ error: Error) -> Bool { + if error is DecodingError { return true } + switch error { + case HistoryError.invalidFile, HistoryError.unsupportedFileVersion: + return true + default: + return false } } @@ -822,6 +1475,47 @@ actor UsageHistory { return (normalized(samples), hadError) } + private func readWorkingSet( + from root: URL + ) -> (samples: [UsageSample], hadError: Bool) { + var samples: [UsageSample] = [] + var hadError = false + var budget = WorkingSetReadBudget() + let directory = installationsDirectory(in: root) + do { + let writers = try boundedWorkingSetWriters(in: directory) + hadError = writers.didReachLimit + writerLoop: for writer in writers.values { + do { + for file in try workingSetFiles(in: writer) { + do { + guard try budget.admit(file) else { + if budget.didReachLimit { + hadError = true + break writerLoop + } + continue + } + samples = boundedWorkingSet( + samples + (try readDailyFileIfPresent( + at: file, + coordinated: false + )) + ) + } catch { + hadError = true + } + } + } catch { + hadError = true + } + } + } catch { + hadError = true + } + return (boundedWorkingSet(samples), hadError) + } + private func readDailyFileIfPresent( at url: URL, generation: Int? = nil, @@ -1019,6 +1713,110 @@ actor UsageHistory { } } + private func boundedWorkingSet(_ samples: [UsageSample]) -> [UsageSample] { + let ordered = normalized(samples) + guard let newest = ordered.last?.observedAt else { return [] } + let cutoff = newest.addingTimeInterval(-Self.workingSetDuration) + let fullResolutionStart = newest.addingTimeInterval( + -Self.fullResolutionDuration + ) + var result: [UsageSample] = [] + var bucketFirst: UsageSample? + var bucketLast: UsageSample? + var bucketCount = 0 + var bucketNumber: Int? + var bucketReset: Date? + + func flushBucket() { + guard let first = bucketFirst else { return } + result.append(first) + if bucketCount > 1, let last = bucketLast { + result.append(last) + } + bucketFirst = nil + bucketLast = nil + bucketCount = 0 + bucketNumber = nil + bucketReset = nil + } + + for sample in ordered where sample.observedAt >= cutoff { + if sample.observedAt >= fullResolutionStart || sample.comparisonBreak { + flushBucket() + result.append(sample) + continue + } + let number = Int(floor( + sample.observedAt.timeIntervalSinceReferenceDate + / Self.historicalBucketDuration + )) + if bucketNumber != number || bucketReset != sample.resetsAt { + flushBucket() + bucketFirst = sample + bucketNumber = number + bucketReset = sample.resetsAt + } + bucketLast = sample + bucketCount += 1 + } + flushBucket() + return Array(result.suffix(Self.maximumWorkingSetSamples)) + } + + private func boundedRangeSamples( + _ samples: [UsageSample] + ) -> (samples: [UsageSample], didDownsample: Bool) { + let ordered = normalized(samples) + guard ordered.count > Self.maximumWorkingSetSamples else { + return (ordered, false) + } + var reduced: [UsageSample] = [] + var bucket: [UsageSample] = [] + var bucketNumber: Int? + var bucketReset: Date? + + func flushBucket() { + guard let first = bucket.first else { return } + reduced.append(first) + if let last = bucket.last, last != first { + reduced.append(last) + } + bucket = [] + bucketNumber = nil + bucketReset = nil + } + + for sample in ordered { + if sample.comparisonBreak { + flushBucket() + reduced.append(sample) + continue + } + let number = Int(floor( + sample.observedAt.timeIntervalSinceReferenceDate + / Self.historicalBucketDuration + )) + if bucketNumber != number || bucketReset != sample.resetsAt { + flushBucket() + bucketNumber = number + bucketReset = sample.resetsAt + } + bucket.append(sample) + } + flushBucket() + guard reduced.count > Self.maximumWorkingSetSamples else { + return (reduced, true) + } + let lastIndex = reduced.count - 1 + let capped = (0 ..< Self.maximumWorkingSetSamples).map { index in + reduced[Int( + (Double(index) * Double(lastIndex) + / Double(Self.maximumWorkingSetSamples - 1)).rounded() + )] + } + return (capped, true) + } + private func installationsDirectory( in root: URL, generation: Int? = nil @@ -1159,6 +1957,35 @@ actor UsageHistory { at url: URL, _ update: (Marker) throws -> Marker ) throws -> Marker { + func updateAtURL(_ target: URL) throws -> Marker { + try self.beforeCoordinatedMarkerRead?(target) + let current = try JSONDecoder().decode( + Marker.self, + from: self.checkedData(at: target) + ) + guard current.version == Self.folderFormatVersion, + let currentGeneration = current.generation, + (1 ... Self.maximumGeneration).contains(currentGeneration) else { + throw current.version == Self.folderFormatVersion + ? HistoryError.invalidFile + : HistoryError.unsupportedFolderVersion + } + let updated = try update(current) + guard updated.version == Self.folderFormatVersion, + let updatedGeneration = updated.generation, + (1 ... Self.maximumGeneration).contains(updatedGeneration) else { + throw updated.version == Self.folderFormatVersion + ? HistoryError.invalidFile + : HistoryError.unsupportedFolderVersion + } + try JSONEncoder().encode(updated).write(to: target, options: .atomic) + return updated + } + guard isUbiquitousItem(url) else { + return try Self.localMarkerUpdateLock.withLock { + try updateAtURL(url) + } + } var result: Result? var coordinationError: NSError? NSFileCoordinator(filePresenter: nil).coordinate( @@ -1166,31 +1993,7 @@ actor UsageHistory { options: .forReplacing, error: &coordinationError ) { coordinatedURL in - result = Result { - try beforeCoordinatedMarkerRead?(coordinatedURL) - let current = try JSONDecoder().decode( - Marker.self, - from: checkedData(at: coordinatedURL) - ) - guard current.version == Self.folderFormatVersion, - let currentGeneration = current.generation, - (1 ... Self.maximumGeneration).contains(currentGeneration) else { - throw current.version == Self.folderFormatVersion - ? HistoryError.invalidFile - : HistoryError.unsupportedFolderVersion - } - let updated = try update(current) - guard updated.version == Self.folderFormatVersion, - let updatedGeneration = updated.generation, - (1 ... Self.maximumGeneration).contains(updatedGeneration) else { - throw updated.version == Self.folderFormatVersion - ? HistoryError.invalidFile - : HistoryError.unsupportedFolderVersion - } - let data = try JSONEncoder().encode(updated) - try data.write(to: coordinatedURL, options: .atomic) - return updated - } + result = Result { try updateAtURL(coordinatedURL) } } if let coordinationError { throw coordinationError } guard let result else { throw HistoryError.unavailableFolder } @@ -1684,6 +2487,46 @@ actor UsageHistory { } } + private func boundedWorkingSetWriters( + in directory: URL + ) throws -> (values: [URL], didReachLimit: Bool) { + var values: [URL] = [] + let preferred = directory.appendingPathComponent( + installationID, + isDirectory: true + ) + var isDirectory: ObjCBool = false + if FileManager.default.fileExists( + atPath: preferred.path, + isDirectory: &isDirectory + ), isDirectory.boolValue { + values.append(preferred) + } + + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + return (values, false) + } + for case let url as URL in enumerator { + guard url.standardizedFileURL != preferred.standardizedFileURL, + (try? url.resourceValues( + forKeys: [.isDirectoryKey] + ).isDirectory) == true else { + continue + } + guard values.count < Self.maximumWorkingSetWriters else { + // ponytail: cap cold fan-out; add a compact partition summary + // if real histories exceed 32 contributing installations. + return (values, true) + } + values.append(url) + } + return (values, false) + } + private func jsonFiles(in directory: URL) throws -> [URL] { try FileManager.default.contentsOfDirectory( at: directory, @@ -1695,6 +2538,23 @@ actor UsageHistory { } } + private func workingSetFiles(in writerDirectory: URL) throws -> [URL] { + guard let manifest = try ensureWriterManifest( + in: writerDirectory, + generation: 1, + coordinated: false + ), let newest = date(forDayName: manifest.newestDay) else { + return [] + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return (0 ..< Self.workingSetFileDays).reversed().compactMap { offset in + calendar.date(byAdding: .day, value: -offset, to: newest).map { + writerDirectory.appendingPathComponent("\(dayName(for: $0)).json") + } + } + } + private func dayName(for date: Date) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! @@ -1702,6 +2562,48 @@ actor UsageHistory { return String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!) } + private func date(forDayName day: String) -> Date? { + let parts = day.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let dayOfMonth = Int(parts[2]) else { + return nil + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + guard let date = calendar.date(from: DateComponents( + year: year, + month: month, + day: dayOfMonth + )), dayName(for: date) == day else { + return nil + } + return date + } + + private func nextDay(after day: String) -> String? { + guard let date = date(forDayName: day) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(byAdding: .day, value: 1, to: date).map(dayName) + } + + private func dayNames(in interval: DateInterval) -> [String] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + var date = calendar.startOfDay(for: interval.start) + var result: [String] = [] + while date <= interval.end, result.count <= Self.workingSetFileDays { + result.append(dayName(for: date)) + guard let next = calendar.date(byAdding: .day, value: 1, to: date) else { + break + } + date = next + } + return result + } + private func message(for error: Error) -> String { switch error { case HistoryError.invalidFolder: diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 2410150..89fabdc 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -864,9 +864,10 @@ enum UsageIntelligenceEngine { sourceState: input.sourceState, now: input.now ) - let weeklyAccountTokenActivity = accountTokenActivity( + let localActivityInterval = tokenActivityInterval( account: input.account, - samples: currentSamples + samples: currentSamples, + accountEpochStartedAt: input.accountEpochStartedAt ) let selectedTokenRange: DateInterval? switch input.analyticsExploration.timeRange { @@ -906,11 +907,7 @@ enum UsageIntelligenceEngine { ) } let localTokenActivity: LocalTokenActivitySnapshot - if let interval = tokenActivityInterval( - account: input.account, - accountActivity: weeklyAccountTokenActivity, - accountEpochStartedAt: input.accountEpochStartedAt - ) { + if let interval = localActivityInterval { if let cached = reusableLocalAggregates?.localTokenActivity, cached.interval.start == interval.start, !tokenFactsAffectIntervalChange( @@ -1137,7 +1134,9 @@ enum UsageIntelligenceEngine { account: input.account, accountSource: .account, interval: observedInterval, - menuBarText: input.account?.mainLimit.map { + menuBarText: observedInterval.flatMap { _ in + input.account?.mainLimit + }.map { "\(Int($0.window.remainingPercent.rounded()))%" } ?? "—", sourceState: input.sourceState, @@ -1260,50 +1259,17 @@ enum UsageIntelligenceEngine { samples: [UsageSample], accountEpochStartedAt: Date? = nil ) -> DateInterval? { - tokenActivityInterval( - account: account, - accountActivity: accountTokenActivity( - account: account, - samples: samples - ), - accountEpochStartedAt: accountEpochStartedAt - ) - } - - private static func tokenActivityInterval( - account: UsageSnapshot?, - accountActivity: AccountTokenActivitySnapshot, - accountEpochStartedAt: Date? - ) -> DateInterval? { - if let interval = accountActivity.interval { - let start = max( - interval.start, - accountEpochStartedAt ?? interval.start - ) - guard interval.end >= start else { return nil } - return DateInterval(start: start, end: interval.end) - } guard let account, let window = account.mainLimit?.window else { return nil } - let start = max( - window.startsAt, - accountEpochStartedAt ?? window.startsAt + let fallbackEnd = min(account.fetchedAt, window.resetsAt) + guard fallbackEnd >= window.startsAt else { return nil } + let fallback = DateInterval( + start: window.startsAt, + end: fallbackEnd ) - let end = min(account.fetchedAt, window.resetsAt) - guard end >= start else { return nil } - return DateInterval(start: start, end: end) - } - - private static func accountTokenActivity( - account: UsageSnapshot?, - samples: [UsageSample] - ) -> AccountTokenActivitySnapshot { - guard let account, let weeklyLimit = account.mainLimit else { - return .unavailable("Account token readings are unavailable") - } - let start = weeklyLimit.window.startsAt + let start = window.startsAt let boundary = samples .filter { $0.lifetimeTokens != nil @@ -1314,58 +1280,55 @@ enum UsageIntelligenceEngine { abs($0.observedAt.timeIntervalSince(start)) < abs($1.observedAt.timeIntervalSince(start)) } + let interval: DateInterval if let currentTokens = account.accountFacts?.lifetimeTokens, let boundary, - let boundaryTokens = boundary.lifetimeTokens { + let boundaryTokens = boundary.lifetimeTokens, + currentTokens >= 0, + boundaryTokens >= 0 { let currentObservedAt = account.accountFacts? .lifetimeTokensObservedAt ?? account.fetchedAt - guard currentObservedAt >= boundary.observedAt else { - return .unavailable( - "No lifetime token reading after the weekly boundary" + if currentObservedAt >= boundary.observedAt { + interval = DateInterval( + start: boundary.observedAt, + end: currentObservedAt ) + } else { + interval = fallback } - guard currentTokens >= 0, boundaryTokens >= 0 else { - return .unavailable("Lifetime token reading is invalid") + } else { + let completeDays = account.tokenHistory + .filter { day in + let end = day.date.addingTimeInterval(86_400) + return day.completeness == .complete + && day.tokens >= 0 + && day.date >= window.startsAt + && end <= fallbackEnd + } + .sorted { $0.date < $1.date } + var total: Int64 = 0 + let hasValidTotal = completeDays.allSatisfy { day in + let sum = total.addingReportingOverflow(day.tokens) + total = sum.partialValue + return !sum.overflow } - guard currentTokens >= boundaryTokens else { - return .unavailable( - "Lifetime token counter decreased", - interval: DateInterval( - start: boundary.observedAt, - end: currentObservedAt - ) + if let first = completeDays.first, + let last = completeDays.last, + hasValidTotal { + interval = DateInterval( + start: first.date, + end: last.date.addingTimeInterval(86_400) ) + } else { + interval = fallback } - let delta = currentTokens.subtractingReportingOverflow( - boundaryTokens - ) - guard !delta.overflow else { - return .unavailable("Lifetime token reading is invalid") - } - return AccountTokenActivitySnapshot( - state: .exact, - tokens: delta.partialValue, - method: .lifetimeDelta, - interval: DateInterval( - start: boundary.observedAt, - end: currentObservedAt - ), - reason: nil - ) - } - - if let dailyActivity = dailyTokenActivity( - account: account, - window: weeklyLimit.window - ) { - return dailyActivity } - - return .unavailable( - account.accountFacts?.lifetimeTokens == nil - ? "Lifetime token readings are unavailable" - : "No lifetime token reading at the weekly boundary" + let clampedStart = max( + interval.start, + accountEpochStartedAt ?? interval.start ) + guard interval.end >= clampedStart else { return nil } + return DateInterval(start: clampedStart, end: interval.end) } private static func selectedRangeAccountTokenActivity( @@ -1625,57 +1588,6 @@ enum UsageIntelligenceEngine { ) } - private static func dailyTokenActivity( - account: UsageSnapshot, - window: UsageWindow - ) -> AccountTokenActivitySnapshot? { - let intervalEnd = min( - account.fetchedAt, - window.resetsAt - ) - let completeDays = account.tokenHistory - .filter { day in - let end = day.date.addingTimeInterval(24 * 60 * 60) - return day.completeness == .complete - && day.tokens >= 0 - && day.date >= window.startsAt - && end <= intervalEnd - } - .sorted { $0.date < $1.date } - guard let first = completeDays.first, - let last = completeDays.last else { - return nil - } - var total: Int64 = 0 - for day in completeDays { - let result = total.addingReportingOverflow(day.tokens) - guard !result.overflow else { return nil } - total = result.partialValue - } - let lastDayEnd = last.date.addingTimeInterval(24 * 60 * 60) - let isContiguous = zip( - completeDays, - completeDays.dropFirst() - ).allSatisfy { previous, next in - next.date == previous.date.addingTimeInterval(24 * 60 * 60) - } - let exactlyMatchesInterval = isContiguous - && first.date == window.startsAt - && lastDayEnd == intervalEnd - return AccountTokenActivitySnapshot( - state: exactlyMatchesInterval ? .exact : .partial, - tokens: total, - method: .dailyBuckets, - interval: DateInterval( - start: first.date, - end: lastDayEnd - ), - reason: exactlyMatchesInterval - ? nil - : "Only complete daily token totals are available" - ) - } - private static func chart( account: UsageSnapshot?, samples: [UsageSample], @@ -1880,7 +1792,7 @@ enum UsageIntelligenceEngine { .sorted { $0.resetsAt < $1.resetsAt } } - private static func projection( + static func projection( reading: UsageSample, window: UsageWindow, rate: Double, @@ -1935,7 +1847,11 @@ enum UsageIntelligenceEngine { sourceState: UsageSourceState, now: Date ) -> UsageFreshness { - guard let account else { return .unavailable } + guard let account, + let reset = account.mainLimit?.window.resetsAt, + reset > now else { + return .unavailable + } if case .failed = sourceState { return .stale } return now.timeIntervalSince(account.fetchedAt) > CurrentUsagePolicy.tightBoundary ? .stale diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index c52c021..ab4f773 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -2,6 +2,67 @@ import AppKit import Combine import Foundation +enum IntegrationWorkPriority: Int, Sendable { + case explicit + case visible + case automatic + case settings +} + +actor IntegrationWorkCoordinator { + private struct Waiter { + let priority: IntegrationWorkPriority + let order: UInt64 + let continuation: CheckedContinuation + } + + private var isAvailable = true + private var nextOrder: UInt64 = 0 + private var waiters: [Waiter] = [] + + func run( + priority: IntegrationWorkPriority, + operation: @Sendable () async -> Void + ) async { + await enter(priority: priority) + if !Task.isCancelled { + await operation() + } + leave() + } + + private func enter(priority: IntegrationWorkPriority) async { + if isAvailable { + isAvailable = false + return + } + let order = nextOrder + nextOrder &+= 1 + // ponytail: cancelled waiters drain without source work; add waiter IDs + // only if bounded-operation metrics show meaningful queue churn. + await withCheckedContinuation { continuation in + waiters.append(Waiter( + priority: priority, + order: order, + continuation: continuation + )) + } + } + + private func leave() { + guard let index = waiters.indices.min(by: { + let lhs = waiters[$0] + let rhs = waiters[$1] + return (lhs.priority.rawValue, lhs.order) + < (rhs.priority.rawValue, rhs.order) + }) else { + isAvailable = true + return + } + waiters.remove(at: index).continuation.resume() + } +} + enum SafetyBufferPolicy { static let defaultValue = 3.0 static let range = 1.0 ... 10.0 @@ -14,6 +75,11 @@ enum SafetyBufferPolicy { @MainActor final class UsageMonitor: ObservableObject { + private struct AccountRefreshRequest: Equatable { + let generation: UInt64 + let priority: IntegrationWorkPriority + } + private static let accountRefreshInterval: TimeInterval = 600 static let safetyBufferKey = "safetyBuffer" @@ -29,6 +95,12 @@ final class UsageMonitor: ObservableObject { ) @Published private(set) var samples: [UsageSample] = [] @Published private(set) var isRefreshing = false + @Published private(set) var isEnabled: Bool + @Published private(set) var historicalReaderSnapshot: UsageReaderSnapshot? + @Published private(set) var historicalRange: DateInterval? + @Published private(set) var historicalRetainedBounds: DateInterval? + @Published private(set) var historicalRangeIssue: String? + @Published private(set) var isLoadingHistoricalRange = false @Published private(set) var syncFolderName: String? @Published private(set) var syncErrorMessage: String? @Published private(set) var historyDeletionStatus: UsageHistory.DeletionStatus = .none @@ -53,15 +125,22 @@ final class UsageMonitor: ObservableObject { private let history: UsageHistory private let codexAssistedHistory: CodexAssistedHistory? private let fetchUsage: () async throws -> CodexFetchResult + private let cancelFetchUsage: @Sendable () async -> Void private let evaluateUsage: @Sendable (UsageIntelligenceInput) -> UsageReaderSnapshot private let localActivityCollector: LocalActivityCollector? private let resetReminderCoordinator: ResetReminderCoordinator + private let integrationWorkCoordinator: IntegrationWorkCoordinator private var historyPartition: AccountHistoryPartition private var accountSnapshot: UsageSnapshot? private var sourceState: UsageSourceState = .available private var previousStatus: PaceStatus? private var cancellables: Set = [] + private var accountRefreshCancellable: AnyCancellable? + private var accountFetchTask: Task? + private var accountCollectionGeneration: UInt64 = 0 + private var accountRefreshRequest: AccountRefreshRequest? + private var menuBarSourceActive: Bool private var started = false private var historyPrepared = false private var historyUsesFiles = false @@ -79,14 +158,25 @@ final class UsageMonitor: ObservableObject { ) private var evaluationGeneration: UInt64 = 0 private var evaluationTask: Task? + private var readerBoundaryTask: Task? + private var historicalRangeGeneration: UInt64 = 0 + private var historicalRangeTask: Task? private var localImportGeneration: UInt64 = 0 private var localImportTask: Task? private var localAnalyticsVisible = false private var localAnalyticsNeedsLoad = false + private var visible = false - convenience init() { + convenience init( + isEnabled: Bool = true, + menuBarSourceActive: Bool = true, + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator() + ) { self.init( defaults: .standard, + isEnabled: isEnabled, + menuBarSourceActive: menuBarSourceActive, localActivityCollector: LocalActivityCollector( projectionSource: ReadOnlyThreadProjectionSource { request in try await CodexClient.shared.threadProjectionResponse( @@ -97,7 +187,8 @@ final class UsageMonitor: ObservableObject { try? await CodexClient.shared.installedCLIVersion() } ), - codexAssistedHistory: CodexAssistedHistory.shared + codexAssistedHistory: CodexAssistedHistory.shared, + integrationWorkCoordinator: integrationWorkCoordinator ) } @@ -105,11 +196,17 @@ final class UsageMonitor: ObservableObject { defaults: UserDefaults, historyDirectory: URL? = nil, startsAutomatically: Bool = true, + isEnabled: Bool = true, + menuBarSourceActive: Bool = true, localActivityCollector: LocalActivityCollector? = nil, resetReminderScheduler: (any ResetReminderScheduling)? = nil, resetReminderNow: @escaping () -> Date = Date.init, codexAssistedHistory: CodexAssistedHistory? = nil, + integrationWorkCoordinator: IntegrationWorkCoordinator = + IntegrationWorkCoordinator(), fetchUsage: @escaping () async throws -> CodexFetchResult = CodexClient.fetch, + cancelFetchUsage: @escaping @Sendable () async -> Void = + { await CodexClient.cancelFetch() }, evaluateUsage: @escaping @Sendable ( UsageIntelligenceInput ) -> UsageReaderSnapshot = { @@ -117,10 +214,14 @@ final class UsageMonitor: ObservableObject { } ) { self.defaults = defaults + self.isEnabled = isEnabled + self.menuBarSourceActive = menuBarSourceActive self.fetchUsage = fetchUsage + self.cancelFetchUsage = cancelFetchUsage self.evaluateUsage = evaluateUsage self.localActivityCollector = localActivityCollector self.codexAssistedHistory = codexAssistedHistory + self.integrationWorkCoordinator = integrationWorkCoordinator let storedSafetyBuffer = defaults.object( forKey: Self.safetyBufferKey ) as? Double @@ -192,8 +293,11 @@ final class UsageMonitor: ObservableObject { guard !started else { return } started = true await resetReminderCoordinator.restore() + if !isEnabled { + await resetReminderCoordinator.reconcile(target: nil) + } publishResetReminderState() - if let cutoff = defaults.object( + if isEnabled, let cutoff = defaults.object( forKey: Self.localHistoryDeletionCutoffKey ) as? Date { await localActivityCollector?.restorePendingHistoryDeletion( @@ -201,43 +305,43 @@ final class UsageMonitor: ObservableObject { ) } - Timer.publish( - every: Self.accountRefreshInterval, - on: .main, - in: .common - ) - .autoconnect() - .sink { [weak self] _ in - Task { - @MainActor in await self?.automaticRefresh() - } - } - .store(in: &cancellables) + startAccountRefreshTimerIfNeeded() NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.didWakeNotification) .sink { [weak self] _ in Task { - @MainActor in await self?.automaticRefresh() + @MainActor in + await self?.refreshSelectedMenuBarSourceIfStale() } } .store(in: &cancellables) - await automaticRefresh() + await refreshSelectedMenuBarSourceIfStale() } func automaticRefresh() async { + guard isEnabled, menuBarSourceActive else { return } await refresh( forceHistorySync: false, - includeLocalActivity: false + includeLocalActivity: false, + priority: .automatic ) } - func refreshAccountIfStale(now: Date = Date()) async { + func refreshAccountIfStale( + now: Date = Date(), + priority: IntegrationWorkPriority = .visible + ) async { + guard isEnabled else { return } guard let fetchedAt = accountSnapshot?.fetchedAt, now.timeIntervalSince(fetchedAt) < Self.accountRefreshInterval else { - await automaticRefresh() + await refresh( + forceHistorySync: false, + includeLocalActivity: false, + priority: priority + ) return } } @@ -246,23 +350,76 @@ final class UsageMonitor: ObservableObject { forceHistorySync: Bool = true, includeLocalActivity: Bool = true ) async { - guard !isRefreshing else { return } + await refresh( + forceHistorySync: forceHistorySync, + includeLocalActivity: includeLocalActivity, + priority: .explicit + ) + } + + private func refresh( + forceHistorySync: Bool, + includeLocalActivity: Bool, + priority: IntegrationWorkPriority + ) async { + guard isEnabled else { return } + if let current = accountRefreshRequest { + guard priority.rawValue < current.priority.rawValue else { return } + accountFetchTask?.cancel() + await cancelFetchUsage() + } + accountCollectionGeneration &+= 1 + let request = AccountRefreshRequest( + generation: accountCollectionGeneration, + priority: priority + ) + accountRefreshRequest = request isRefreshing = true - defer { isRefreshing = false } + await integrationWorkCoordinator.run(priority: priority) { + @MainActor [weak self] in + guard let self, self.isEnabled, + self.accountRefreshRequest == request else { + return + } + await self.performRefresh( + forceHistorySync: forceHistorySync, + includeLocalActivity: includeLocalActivity, + generation: request.generation + ) + } + if accountRefreshRequest == request { + accountRefreshRequest = nil + isRefreshing = false + } + } + + private func performRefresh( + forceHistorySync: Bool, + includeLocalActivity: Bool, + generation: UInt64 + ) async { + defer { + if generation == accountCollectionGeneration { + accountFetchTask = nil + } + } if includeLocalActivity { cancelLocalImport() } await restoreHistoryIfAvailable() let fetchTask = Task { try await fetchUsage() } + accountFetchTask = fetchTask do { let result = try await fetchTask.value + guard canPublishAccountWork(generation) else { return } guard let account = result.account else { historyMatchesCurrentSnapshot = false await exchangeRestoredHistoryIfAvailable( force: forceHistorySync ) + guard canPublishAccountWork(generation) else { return } accountSnapshot = result.snapshot sourceState = .available if localAnalyticsVisible, @@ -272,6 +429,7 @@ final class UsageMonitor: ObservableObject { observedAt: result.snapshot.fetchedAt, identityVerified: false ) + guard canPublishAccountWork(generation) else { return } } let published = await recalculate( now: result.snapshot.fetchedAt @@ -290,13 +448,17 @@ final class UsageMonitor: ObservableObject { planType: result.planType, observedAt: result.snapshot.fetchedAt ) + guard canPublishAccountWork(generation) else { return } await prepareHistory(legacySamples: legacySamples) + guard canPublishAccountWork(generation) else { return } if !historyUsesFiles { let historyState = await history.load(legacySamples: samples) + guard canPublishAccountWork(generation) else { return } apply(historyState) historyUsesFiles = historyState.errorMessage == nil } let historyState = await exchangeHistory(force: forceHistorySync) + guard canPublishAccountWork(generation) else { return } apply(historyState, configuredFolderName: configuredSyncDirectory?.lastPathComponent) repairInitialAccountEpochIfNeeded() let exchangeErrorMessage = historyState.errorMessage @@ -311,6 +473,7 @@ final class UsageMonitor: ObservableObject { accountEpochStartedAt == newSnapshot.fetchedAt ) let recordedState = await history.record(sample) + guard canPublishAccountWork(generation) else { return } apply( recordedState, configuredFolderName: configuredSyncDirectory?.lastPathComponent @@ -329,6 +492,7 @@ final class UsageMonitor: ObservableObject { for: newSnapshot, observedAt: newSnapshot.fetchedAt ) + guard canPublishAccountWork(generation) else { return } } let published = await recalculate(now: newSnapshot.fetchedAt) persist() @@ -336,9 +500,11 @@ final class UsageMonitor: ObservableObject { await reconcileResetReminder() } } catch { + guard canPublishAccountWork(generation) else { return } await exchangeRestoredHistoryIfAvailable( force: forceHistorySync ) + guard canPublishAccountWork(generation) else { return } sourceState = .failed( (error as? CodexClientError)?.localizedDescription ?? "Couldn’t read Codex usage. Try refreshing again." @@ -351,6 +517,7 @@ final class UsageMonitor: ObservableObject { observedAt: Date(), identityVerified: false ) + guard canPublishAccountWork(generation) else { return } } _ = await recalculate() persist() @@ -359,6 +526,7 @@ final class UsageMonitor: ObservableObject { func setLocalAnalyticsVisible(_ isVisible: Bool) async { if isVisible { + guard isEnabled else { return } if !localAnalyticsVisible { localAnalyticsVisible = true localAnalyticsNeedsLoad = true @@ -384,17 +552,273 @@ final class UsageMonitor: ObservableObject { } guard localAnalyticsVisible, localAnalyticsNeedsLoad, - let accountSnapshot else { + accountSnapshot != nil else { + return + } + await integrationWorkCoordinator.run(priority: .visible) { + @MainActor [weak self] in + guard let self, + self.isEnabled, + self.localAnalyticsVisible, + self.localAnalyticsNeedsLoad, + let accountSnapshot = self.accountSnapshot else { + return + } + let identityVerified = self.sourceState == .available + && self.historyAccountIdentity != nil + await self.refreshLocalActivity( + for: accountSnapshot, + observedAt: identityVerified + ? accountSnapshot.fetchedAt + : Date(), + identityVerified: identityVerified + ) + _ = await self.recalculate() + } + } + + func setVisible(_ visible: Bool) async { + guard visible != self.visible else { return } + self.visible = visible + scheduleReaderBoundary() + if !visible, !menuBarSourceActive { + await cancelAccountCollection() + } + if !visible { + clearHistoricalRange() + } + } + + func setEnabled(_ enabled: Bool) async { + guard enabled != isEnabled else { return } + isEnabled = enabled + accountCollectionGeneration &+= 1 + accountFetchTask?.cancel() + accountFetchTask = nil + accountRefreshRequest = nil + isRefreshing = false + + if enabled { + startAccountRefreshTimerIfNeeded() + scheduleReaderBoundary() + while isRefreshing { + guard !Task.isCancelled else { return } + await Task.yield() + } + await refreshSelectedMenuBarSourceIfStale() return } - let identityVerified = sourceState == .available - && historyAccountIdentity != nil - await refreshLocalActivity( - for: accountSnapshot, - observedAt: identityVerified ? accountSnapshot.fetchedAt : Date(), - identityVerified: identityVerified + + accountRefreshCancellable?.cancel() + accountRefreshCancellable = nil + await cancelFetchUsage() + readerBoundaryTask?.cancel() + readerBoundaryTask = nil + clearHistoricalRange() + cancelLocalImport() + evaluationGeneration &+= 1 + evaluationTask?.cancel() + evaluationTask = nil + localAnalyticsVisible = false + localAnalyticsNeedsLoad = false + localActivityCollection = .unavailable( + "Codex local records are unavailable" ) - _ = await recalculate() + await localActivityCollector?.releaseCachedFacts() + _ = await history.disconnect() + await resetReminderCoordinator.reconcile(target: nil) + publishResetReminderState() + } + + func setMenuBarSourceActive(_ active: Bool) async { + guard active != menuBarSourceActive else { return } + menuBarSourceActive = active + if !active { + accountRefreshCancellable?.cancel() + accountRefreshCancellable = nil + scheduleReaderBoundary() + if !visible { + await cancelAccountCollection() + } + return + } + guard isEnabled else { return } + scheduleReaderBoundary() + startAccountRefreshTimerIfNeeded() + await refreshAccountIfStale(priority: .automatic) + } + + private func refreshSelectedMenuBarSourceIfStale() async { + guard menuBarSourceActive else { return } + await refreshAccountIfStale(priority: .automatic) + } + + private func startAccountRefreshTimerIfNeeded() { + guard started, isEnabled, menuBarSourceActive, + accountRefreshCancellable == nil else { + return + } + accountRefreshCancellable = Timer.publish( + every: Self.accountRefreshInterval, + on: .main, + in: .common + ) + .autoconnect() + .sink { [weak self] _ in + Task { + @MainActor in await self?.automaticRefresh() + } + } + } + + private func canPublishAccountWork(_ generation: UInt64) -> Bool { + isEnabled + && generation == accountCollectionGeneration + && !Task.isCancelled + } + + private func cancelAccountCollection() async { + accountCollectionGeneration &+= 1 + accountFetchTask?.cancel() + accountFetchTask = nil + accountRefreshRequest = nil + isRefreshing = false + await cancelFetchUsage() + } + + var canLoadEarlierHistory: Bool { + guard !samples.isEmpty else { return false } + guard let historicalRange, + let retained = historicalRetainedBounds else { + return true + } + return retained.start < historicalRange.start + } + + func loadEarlierHistory( + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard let end = historicalRange?.start + ?? samples.first?.observedAt else { return } + await loadHistoricalRange( + DateInterval( + start: end.addingTimeInterval(-84 * 86_400), + end: end + ), + exploration: exploration, + dispositions: dispositions + ) + } + + func loadLaterHistory( + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard let historicalRange else { return } + guard let latestStart = samples.first?.observedAt, + historicalRange.end < latestStart else { + clearHistoricalRange() + return + } + let end = min( + historicalRange.end.addingTimeInterval(84 * 86_400), + latestStart + ) + await loadHistoricalRange( + DateInterval( + start: end.addingTimeInterval(-84 * 86_400), + end: end + ), + exploration: exploration, + dispositions: dispositions + ) + } + + func clearHistoricalRange() { + historicalRangeGeneration &+= 1 + historicalRangeTask?.cancel() + historicalRangeTask = nil + historicalReaderSnapshot = nil + historicalRange = nil + historicalRetainedBounds = nil + historicalRangeIssue = nil + isLoadingHistoricalRange = false + } + + private func loadHistoricalRange( + _ interval: DateInterval, + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition] + ) async { + guard isEnabled, visible else { return } + historicalRangeGeneration &+= 1 + let generation = historicalRangeGeneration + historicalRangeTask?.cancel() + isLoadingHistoricalRange = true + historicalRangeIssue = nil + var rangeExploration = exploration + rangeExploration.timeRange = .selected + rangeExploration.visibleRange = interval + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performHistoricalRangeLoad( + interval, + exploration: rangeExploration, + dispositions: dispositions, + generation: generation + ) + } + historicalRangeTask = task + await task.value + if historicalRangeGeneration == generation { + historicalRangeTask = nil + isLoadingHistoricalRange = false + } + } + + private func performHistoricalRangeLoad( + _ interval: DateInterval, + exploration: AnalyticsExplorationState, + dispositions: [String: InsightDisposition], + generation: UInt64 + ) async { + await integrationWorkCoordinator.run(priority: .visible) { + @MainActor [weak self] in + guard let self, self.isEnabled, self.visible, + self.historicalRangeGeneration == generation, + !Task.isCancelled, + let view = await self.history.rangeView(for: interval) else { + return + } + let input = self.evaluationInput( + analyticsExploration: exploration, + insightDispositions: dispositions, + historySamples: view.samples + ) + let evaluateUsage = self.evaluateUsage + let evaluation = Task.detached(priority: .userInitiated) { + Task.isCancelled ? nil : evaluateUsage(input) + } + let snapshot = await withTaskCancellationHandler { + await evaluation.value + } onCancel: { + evaluation.cancel() + } + guard let snapshot, self.isEnabled, self.visible, + self.historicalRangeGeneration == generation, + !Task.isCancelled else { return } + self.historicalReaderSnapshot = snapshot + self.historicalRange = interval + self.historicalRetainedBounds = view.retainedBounds + self.historicalRangeIssue = if view.hadReadError { + "Some history couldn’t be read." + } else if view.samples.isEmpty { + "No usage observations in this range." + } else { + nil + } + } } func updateSafetyBuffer(_ value: Double) { @@ -854,7 +1278,8 @@ final class UsageMonitor: ObservableObject { safetyBuffer: Double? = nil, now: Date = Date(), analyticsExploration: AnalyticsExplorationState? = nil, - insightDispositions: [String: InsightDisposition]? = nil + insightDispositions: [String: InsightDisposition]? = nil, + historySamples: [UsageSample]? = nil ) -> UsageIntelligenceInput { let storedBuffer = defaults.object(forKey: Self.safetyBufferKey) as? Double let buffer = SafetyBufferPolicy.normalized( @@ -862,7 +1287,9 @@ final class UsageMonitor: ObservableObject { ) return UsageIntelligenceInput( account: accountSnapshot, - samples: historyMatchesCurrentSnapshot ? samples : [], + samples: historyMatchesCurrentSnapshot + ? (historySamples ?? samples) + : [], safetyBuffer: buffer, sourceState: sourceState, now: now, @@ -908,12 +1335,44 @@ final class UsageMonitor: ObservableObject { return false } readerSnapshot = snapshot + scheduleReaderBoundary() if let status = snapshot.guidance?.status { previousStatus = status } return true } + private func scheduleReaderBoundary() { + readerBoundaryTask?.cancel() + guard isEnabled, menuBarSourceActive || visible, + let account = readerSnapshot.account, + let reset = account.mainLimit?.window.resetsAt else { + readerBoundaryTask = nil + return + } + let now = Date() + let candidates = [ + account.fetchedAt.addingTimeInterval(15 * 60), + reset + ].filter { $0 > now }.sorted() + guard let boundary = candidates.first else { + readerBoundaryTask = nil + return + } + readerBoundaryTask = Task { [weak self] in + do { + try await Task.sleep( + for: .seconds(boundary.timeIntervalSinceNow) + ) + } catch { + return + } + guard let self else { return } + readerBoundaryTask = nil + _ = await recalculate() + } + } + func analyticsPreferencesDidChange( exploration: AnalyticsExplorationState, dispositions: [String: InsightDisposition] @@ -1126,7 +1585,8 @@ final class UsageMonitor: ObservableObject { accountBindingToken: defaults.string( forKey: Self.historySyncAccountBindingKey ), - bindsUnresolvedDeletionTarget: true + bindsUnresolvedDeletionTarget: true, + performFullReconciliation: false ) historyConnectionActive = connectedState.folderName != nil apply(connectedState, configuredFolderName: directory.lastPathComponent) @@ -1198,7 +1658,8 @@ final class UsageMonitor: ObservableObject { accountIdentity: historyAccountIdentity, accountBindingToken: defaults.string( forKey: Self.historySyncAccountBindingKey - ) + ), + performFullReconciliation: false ) historyConnectionActive = state.folderName != nil return state diff --git a/Sources/CodexLimits/UsageOverviewSnapshot.swift b/Sources/CodexLimits/UsageOverviewSnapshot.swift new file mode 100644 index 0000000..aa4e50b --- /dev/null +++ b/Sources/CodexLimits/UsageOverviewSnapshot.swift @@ -0,0 +1,90 @@ +import ClaudeIntegrationCore +import Foundation + +/// A bounded view of real observations in the current allowance period. +struct UsageOverviewSnapshot: Equatable, Sendable { + static let maximumPoints = 256 + + let range: DateInterval + let target: [UsageChartPoint] + let observedSegments: [[UsageChartPoint]] + let latest: UsageChartPoint? + + init?(chart: UsageChartSnapshot, window: UsageWindow, now: Date) { + guard chart.observedSource == .account, window.isValid, now.isSupportedUsageDate, + window.startsAt <= now, window.resetsAt > now, + chart.currentAllowanceReset == window.resetsAt else { return nil } + range = DateInterval(start: window.startsAt, end: window.resetsAt) + target = Self.compact(chart.target.filter { + Self.isValid($0) && $0.date >= window.startsAt && $0.date <= window.resetsAt + }, limit: 2) + + var segments: [[UsageChartPoint]] = [] + for original in chart.observedSegments { + var segment: [UsageChartPoint] = [] + for point in original { + guard Self.isValid(point), point.date >= window.startsAt, point.date <= now else { + if !segment.isEmpty { segments.append(segment); segment = [] } + continue + } + if let previous = segment.last, point.date <= previous.date { + segments.append(segment) + segment = [] + } + segment.append(point) + } + if !segment.isEmpty { segments.append(segment) } + } + segments.sort { $0[0].date < $1[0].date } + latest = segments.flatMap { $0 }.max { $0.date < $1.date } + let budget = Self.maximumPoints - target.count - (latest == nil ? 0 : 1) + // Preserve both ends of every retained segment; omit older segments if + // the boundary points alone exceed the thumbnail's fixed display budget. + var endpointCount = segments.reduce(0) { $0 + min(2, $1.count) } + var firstKept = 0 + while endpointCount > budget { + endpointCount -= min(2, segments[firstKept].count) + firstKept += 1 + } + let retained = segments.dropFirst(firstKept) + var remainingInterior = retained.reduce(0) { $0 + max(0, $1.count - 2) } + var extraBudget = budget - endpointCount + observedSegments = retained.map { segment in + let interior = max(0, segment.count - 2) + let extra = remainingInterior > 0 + ? min(interior, extraBudget * interior / remainingInterior) : 0 + extraBudget -= extra + remainingInterior -= interior + return Self.compact(segment, limit: min(2, segment.count) + extra) + } + } + + init?(observations: [AllowanceObservation], current: AllowanceObservation?, now: Date, safetyBuffer: Double) { + guard let current, Self.historyReadStart(current: current, now: now) != nil, + let content = IntegrationAllowanceChart( + metric: current.metric, + observations: observations.filter { $0.resetsAt == current.resetsAt }, + current: current, now: now, isStale: true, safetyBuffer: safetyBuffer + ) else { return nil } + self.init(chart: content.chart, window: content.window, now: now) + } + + /// A 31-day elapsed range touches at most 32 UTC daily files. + static func historyReadStart(current: AllowanceObservation?, now: Date) -> Date? { + guard let current, current.isValid, now.isSupportedUsageDate, + current.observedAt <= now, current.resetsAt > now else { return nil } + let earliest = now.addingTimeInterval(-31 * 86_400) + return max(current.startsAt ?? earliest, earliest) + } + + private static func isValid(_ point: UsageChartPoint) -> Bool { + point.date.isSupportedUsageDate && point.remaining.isFinite + && (0 ... 100).contains(point.remaining) + } + + private static func compact(_ points: [UsageChartPoint], limit: Int) -> [UsageChartPoint] { + guard points.count > limit else { return points } + guard limit > 1 else { return Array(points.suffix(limit)) } + return (0 ..< limit).map { points[$0 * (points.count - 1) / (limit - 1)] } + } +} diff --git a/Sources/CodexLimits/UsageReceipts.swift b/Sources/CodexLimits/UsageReceipts.swift index 525bc1a..b51b903 100644 --- a/Sources/CodexLimits/UsageReceipts.swift +++ b/Sources/CodexLimits/UsageReceipts.swift @@ -1206,97 +1206,6 @@ struct UsageReceiptSnapshot: Equatable, Sendable { date >= interval.start && date < interval.end } - private static func filterGapReason( - in contributions: [Contribution], - filters: WorkspaceFilters - ) -> String? { - filterGapReason( - in: contributions.map { - FilterMetadata( - projectID: $0.projectLabel, - taskTreeID: $0.rootTaskID, - model: $0.context?.effectiveModel, - reasoning: $0.context?.reasoning - ) - }, - filters: filters, - subject: "Some local activity", - verb: "has" - ) - } - - private static func filterGapReason( - in diagnostics: [DiagnosticContribution], - filters: WorkspaceFilters - ) -> String? { - filterGapReason( - in: diagnostics.map { - FilterMetadata( - projectID: $0.projectLabel, - taskTreeID: $0.rootTaskID, - model: $0.context?.effectiveModel, - reasoning: $0.context?.reasoning - ) - }, - filters: filters, - subject: "Some local diagnostics", - verb: "have" - ) - } - - private static func filterGapReason( - in metadata: [FilterMetadata], - filters: WorkspaceFilters, - subject: String, - verb: String - ) -> String? { - if filters.projectID != nil, - metadata.contains(where: { - $0.projectID == nil - && couldMatch( - $0, - filters: filters, - ignoring: .project - ) - }) { - return "\(subject) \(verb) no Project metadata" - } - if filters.taskTreeID != nil, - metadata.contains(where: { - $0.taskTreeID == nil - && couldMatch( - $0, - filters: filters, - ignoring: .taskTree - ) - }) { - return "\(subject) \(verb) no Task metadata" - } - if filters.model != nil, - metadata.contains(where: { - $0.model == nil - && couldMatch( - $0, - filters: filters, - ignoring: .model - ) - }) { - return "\(subject) \(verb) no model metadata" - } - if filters.reasoning != nil, - metadata.contains(where: { - $0.reasoning == nil - && couldMatch( - $0, - filters: filters, - ignoring: .reasoning - ) - }) { - return "\(subject) \(verb) no reasoning metadata" - } - return nil - } - private static func couldMatch( _ metadata: FilterMetadata, filters: WorkspaceFilters, diff --git a/Sources/CodexLimitsClaudeRelay/main.swift b/Sources/CodexLimitsClaudeRelay/main.swift new file mode 100644 index 0000000..82ae936 --- /dev/null +++ b/Sources/CodexLimitsClaudeRelay/main.swift @@ -0,0 +1,40 @@ +import ClaudeIntegrationCore +import Foundation + +let receivedAt = Date() +let arguments = CommandLine.arguments +guard arguments.count == 5, + arguments[1] == "--cache", + arguments[3] == "--enabled-marker" else { + exit(64) +} +let cacheURL = URL(fileURLWithPath: arguments[2]) +let markerURL = URL(fileURLWithPath: arguments[4]) + +do { + let input = try ClaudeRelay.readBoundedInput(from: .standardInput) + let snapshot = try ClaudeRelay.decode(input, observedAt: receivedAt) + let didWrite = try ClaudeRelay.storeIfNewer( + snapshot, + at: cacheURL, + enabledMarkerURL: markerURL + ) + if didWrite { + DistributedNotificationCenter.default().postNotificationName( + Notification.Name(ClaudeRelay.snapshotChangedNotificationName), + object: nil, + deliverImmediately: true + ) + } + FileHandle.standardOutput.write( + Data((ClaudeRelay.statusLine(for: snapshot) + "\n").utf8) + ) +} catch ClaudeRelayError.disabled { + exit(0) +} catch ClaudeRelayError.noAllowance { + FileHandle.standardOutput.write( + Data((ClaudeRelay.unavailableStatusLine + "\n").utf8) + ) +} catch { + exit(65) +} diff --git a/Tests/CodexLimitsTests/AllowanceHistoryTests.swift b/Tests/CodexLimitsTests/AllowanceHistoryTests.swift new file mode 100644 index 0000000..6b30069 --- /dev/null +++ b/Tests/CodexLimitsTests/AllowanceHistoryTests.swift @@ -0,0 +1,328 @@ +import ClaudeIntegrationCore +import Combine +import Darwin +import Foundation +import XCTest +@testable import CodexLimits + +final class AllowanceHistoryTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + func testDailyAppendRepairsAnInterruptedTailAndDeduplicatesCacheSeeds() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let first = point(now.addingTimeInterval(-600), remaining: 90) + let second = point(now.addingTimeInterval(-300), remaining: 80) + let third = point(now.addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append([first, second], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + let original = try Data(contentsOf: file) + try AllowanceHistory.append([first, second], in: directory) + XCTAssertEqual(try Data(contentsOf: file), original) + + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(#"{"metric":"interrupted"#.utf8)) + try handle.close() + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first, second]) + try AllowanceHistory.append([third], in: directory) + + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first, second, third]) + XCTAssertEqual(try Data(contentsOf: file).last, 10) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber, 0o700) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber, 0o600) + } + + func testReadUsesExactRollingBoundsWithoutDeletingOlderHistory() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let old = point(now.addingTimeInterval(-90 * 86_400), remaining: 90) + let boundary = point(now.addingTimeInterval(-84 * 86_400), remaining: 80) + let current = point(now.addingTimeInterval(-60), remaining: 70) + let future = point(now.addingTimeInterval(60), remaining: 60) + try AllowanceHistory.append([old, boundary, current, future], in: directory) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).sorted { $0.lastPathComponent < $1.lastPathComponent } + let oldestFile = try XCTUnwrap(files.first) + // Out-of-range archives are neither enumerated nor decoded by the reader. + try Data("unread old archive".utf8).write(to: oldestFile) + + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [boundary, current]) + XCTAssertTrue(FileManager.default.fileExists(atPath: oldestFile.path)) + try AllowanceHistory.delete(in: directory) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) + } + + func testInvalidCommittedRecordsAndReadBoundsAreReported() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + try AllowanceHistory.append([point(now, remaining: 90)], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + try Data("not-json\n".utf8).write(to: file) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .invalidRecord) + } + try Data(repeating: 0x20, count: AllowanceHistory.maximumFileBytes + 1).write(to: file) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .readLimitExceeded) + } + XCTAssertThrowsError(try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: .nan, resetsAt: now.addingTimeInterval(60)) + ], in: directory)) + XCTAssertThrowsError(try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: 50, resetsAt: now.addingTimeInterval(60), startsAt: now.addingTimeInterval(1)) + ], in: directory)) + XCTAssertFalse(AllowanceObservation( + metric: "grok-weekly", observedAt: now, remainingPercent: 50, + resetsAt: now.addingTimeInterval(60), source: "raw\nresponse" + ).isValid) + let expiredDirectory = temporaryDirectory().appendingPathComponent("History") + try AllowanceHistory.append([ + AllowanceObservation(metric: "grok-weekly", observedAt: now, remainingPercent: 50, resetsAt: now) + ], in: expiredDirectory) + XCTAssertFalse(FileManager.default.fileExists(atPath: expiredDirectory.path)) + } + + func testCurrentPeriodReadSkipsOlderDailyFilesAndKeepsExistingReadBounds() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let old = point(now.addingTimeInterval(-20 * 86_400), remaining: 90) + let current = point(now.addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append([old, current], in: directory) + let oldestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.first) + try Data("invalid older archive\n".utf8).write(to: oldestFile) + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now, since: now.addingTimeInterval(-7 * 86_400)), [current]) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now)) + XCTAssertTrue(try AllowanceHistory.read(in: directory, now: now, since: now.addingTimeInterval(1)).isEmpty) + XCTAssertThrowsError(try AllowanceHistory.read(in: directory, now: now, since: Date(timeIntervalSince1970: .nan))) + } + + func testClockRollbackKeepsFutureHistoryButDoesNotSuppressCurrentObservations() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let future = point(now.addingTimeInterval(3_600), remaining: 90) + let current = point(now, remaining: 70) + let delayed = point(now.addingTimeInterval(-120), remaining: 80) + try AllowanceHistory.append([future], in: directory, now: { self.now.addingTimeInterval(3_600) }) + try AllowanceHistory.append([current], in: directory, now: { self.now }) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + let recovered = try Data(contentsOf: file) + + try AllowanceHistory.append([delayed], in: directory, now: { self.now }) + try AllowanceHistory.append([current, future], in: directory, now: { self.now }) + + XCTAssertEqual(try Data(contentsOf: file), recovered, "Delayed writers and retries must remain deduplicated") + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [current]) + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now.addingTimeInterval(7_200)), [current, future]) + } + + func testContendedDayFileReturnsWithoutAnUnboundedWait() throws { + let directory = temporaryDirectory().appendingPathComponent("History") + let first = point(now.addingTimeInterval(-60), remaining: 90) + try AllowanceHistory.append([first], in: directory) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first) + let descriptor = open(file.path, O_RDWR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let started = ProcessInfo.processInfo.systemUptime + + XCTAssertThrowsError(try AllowanceHistory.append([point(now, remaining: 80)], in: directory)) { + XCTAssertEqual($0 as? AllowanceHistoryError, .writeFailed) + } + + XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - started, 1) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + XCTAssertEqual(try AllowanceHistory.read(in: directory, now: now), [first]) + } + + @MainActor + func testHiddenClaudeRelayRecordsHistoryAndVisibleStoreMigratesAndDeletesIt() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let first = claudeSnapshot(at: Date().addingTimeInterval(-180), remaining: 90) + let second = claudeSnapshot(at: first.observedAt.addingTimeInterval(60), remaining: 80) + let third = claudeSnapshot(at: second.observedAt.addingTimeInterval(60), remaining: 70) + try JSONEncoder().encode(first).write(to: fixture.cacheURL) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + for snapshot in [second, third] { + XCTAssertTrue(try ClaudeRelay.storeIfNewer(snapshot, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + } + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.snapshot) + + await store.setVisible(true) + + XCTAssertEqual(store.snapshot, third) + XCTAssertEqual(store.history.count, 6) + XCTAssertEqual(Set(store.history.map(\.metric)), ["claude-five-hour", "claude-seven-day"]) + XCTAssertEqual(Set(store.history.compactMap(\.source)), ["statusLine.rate_limits"]) + XCTAssertEqual(store.history.first?.observedAt, first.observedAt) + XCTAssertNil(store.historyIssue) + await store.setVisible(false) + XCTAssertTrue(store.history.isEmpty) + await store.setVisible(true) + XCTAssertEqual(store.history.count, 6) + + await store.deleteData() + + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.snapshot) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.historyDirectory.path)) + XCTAssertFalse(store.hasStoredData) + XCTAssertThrowsError(try ClaudeRelay.storeIfNewer(third, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.historyDirectory.path)) + } + + @MainActor + func testClaudeHistoryFailureKeepsTheLatestAllowanceAndReportsTheIssue() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + try Data("blocked history directory".utf8).write(to: fixture.historyDirectory) + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 65) + + XCTAssertTrue(try ClaudeRelay.storeIfNewer(snapshot, at: fixture.cacheURL, enabledMarkerURL: fixture.enabledMarkerURL)) + let persisted = try ClaudeRelay.readSnapshot(at: fixture.cacheURL) + XCTAssertEqual(persisted.sevenDay, snapshot.sevenDay) + XCTAssertEqual(persisted.historyWriteFailed, true) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + await store.setVisible(true) + XCTAssertEqual(store.snapshot?.sevenDay, snapshot.sevenDay) + XCTAssertNotNil(store.historyIssue) + XCTAssertTrue(store.history.isEmpty) + } + + @MainActor + func testOverviewDoesNotReadHistoryAndDetailStillLoadsWithoutLatestCache() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 70) + try AllowanceHistory.append(snapshot.historyObservations, in: fixture.historyDirectory) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + + await store.setVisible(true, includeHistory: false) + XCTAssertTrue(store.history.isEmpty) + XCTAssertNil(store.overview) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + + XCTAssertNil(store.snapshot) + XCTAssertEqual(store.history.count, 2) + XCTAssertNil(store.historyIssue) + await store.setVisible(false) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil).first) + try Data("invalid history\n".utf8).write(to: file) + await store.setVisible(true, includeHistory: false) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + XCTAssertNotNil(store.historyIssue) + } + + @MainActor + func testClaudeOverviewUsesOnlyCurrentPeriodAndReleasesItsCompactSnapshot() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let snapshot = claudeSnapshot(at: Date().addingTimeInterval(-60), remaining: 70) + try JSONEncoder().encode(snapshot).write(to: fixture.cacheURL) + let previous = snapshot.historyObservations.map { + AllowanceObservation(metric: $0.metric, observedAt: $0.observedAt.addingTimeInterval(-600), + remainingPercent: 80, resetsAt: $0.resetsAt, startsAt: $0.startsAt, source: $0.source) + } + try AllowanceHistory.append(previous, in: fixture.historyDirectory) + let old = claudeSnapshot(at: Date().addingTimeInterval(-20 * 86_400), remaining: 90) + let oldPoint = AllowanceObservation(metric: "claude-seven-day", observedAt: old.observedAt, + remainingPercent: 90, resetsAt: old.observedAt.addingTimeInterval(60)) + try AllowanceHistory.append([oldPoint], in: fixture.historyDirectory) + let oldestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.first) + try Data("invalid old archive\n".utf8).write(to: oldestFile) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + + await store.setVisible(true, includeHistory: false, safetyBuffer: 5) + + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 2) + XCTAssertEqual(store.overview?.latest?.remaining, 70) + XCTAssertEqual(store.overview?.target.last?.remaining, 5) + XCTAssertNil(store.historyIssue) + await store.setVisible(true) + XCTAssertNil(store.overview) + XCTAssertNotNil(store.historyIssue) + await store.setVisible(false) + let newestFile = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent }.last) + try Data("invalid current archive\n".utf8).write(to: newestFile) + await store.setVisible(true, includeHistory: false) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + XCTAssertNotNil(store.historyIssue) + await store.setEnabled(false) + XCTAssertNil(store.overview) + await store.deleteData() + XCTAssertNil(store.overview) + } + + @MainActor + func testNotificationDuringHistoryReadTriggersOneLatestCacheRead() async throws { + let fixture = try claudeFixture() + let service = ClaudeCodeSetupService(paths: fixture) + _ = try await service.setUp() + let observedAt = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970 / 86_400) * 86_400 - 43_200) + let first = claudeSnapshot(at: observedAt, remaining: 90) + let second = claudeSnapshot(at: observedAt.addingTimeInterval(60), remaining: 80) + try JSONEncoder().encode(first).write(to: fixture.cacheURL) + let store = ClaudeCodeIntegrationStore(isEnabled: true, menuBarSourceActive: false, service: service) + await store.setVisible(true) + let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: fixture.historyDirectory, includingPropertiesForKeys: nil).first) + let descriptor = open(file.path, O_RDWR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + var cacheWasRead = false + let observation = store.$snapshot.dropFirst().sink { _ in cacheWasRead = true } + defer { observation.cancel() } + let reading = Task { await store.checkForNewObservation(priority: .automatic) } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while !cacheWasRead, ContinuousClock.now < deadline { await Task.yield() } + XCTAssertTrue(cacheWasRead) + try await Task.sleep(for: .milliseconds(10)) + + try JSONEncoder().encode(second).write(to: fixture.cacheURL, options: .atomic) + await store.checkForNewObservation(priority: .automatic) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + await reading.value + while store.snapshot?.observedAt != second.observedAt, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + + XCTAssertEqual(store.snapshot?.observedAt, second.observedAt) + XCTAssertEqual(store.snapshot?.sevenDay?.remainingPercent, 80) + } + + private func point(_ observedAt: Date, remaining: Double) -> AllowanceObservation { + AllowanceObservation( + metric: "grok-weekly", observedAt: observedAt, + remainingPercent: remaining, resetsAt: observedAt.addingTimeInterval(86_400), + startsAt: observedAt.addingTimeInterval(-6 * 86_400), source: "creditUsagePercent" + ) + } + + private func claudeSnapshot(at observedAt: Date, remaining: Double) -> ClaudeAllowanceSnapshot { + let reset = Date().addingTimeInterval(3_600) + return ClaudeAllowanceSnapshot( + observedAt: observedAt, cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot(remainingPercent: remaining, resetsAt: reset), + sevenDay: ClaudeAllowanceWindowSnapshot(remainingPercent: remaining, resetsAt: reset) + ) + } + + private func claudeFixture() throws -> ClaudeCodeIntegrationPaths { + let root = temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let executable = root.appendingPathComponent("claude") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + return ClaudeCodeIntegrationPaths( + executableCandidates: [executable], settingsURL: root.appendingPathComponent("settings.json"), + dataDirectory: root.appendingPathComponent("data"), helperURL: executable + ) + } +} diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index a7736ba..f8d4c5c 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -116,11 +116,16 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertFalse(AnalyticsGraph.concurrency.usesAccountScope) } - func testLightweightCoreOffersOnlyAccountGraphs() { - XCTAssertEqual( - AnalyticsGraph.coreCases, - [.usageRemaining, .tokenActivity] - ) + func testLocalAnalyticsVisibilityMatchesTheSelectedSurface() { + var state = AnalyticsExplorationState.initial + XCTAssertFalse(state.usesLocalAnalytics) + + state.graph = .usagePerToken + XCTAssertTrue(state.usesLocalAnalytics) + + state.section = .facts + state.graph = .usageRemaining + XCTAssertTrue(state.usesLocalAnalytics) } func testRestoredLocalGraphFallsBackToUsageRemaining() throws { @@ -440,152 +445,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testAccountTokenIntervalSelectionPreservesFactualIdentity() { - let range = DateInterval( - start: Date(timeIntervalSince1970: 1_000), - end: Date(timeIntervalSince1970: 10_000) - ) - let positive = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 2_000), - end: Date(timeIntervalSince1970: 3_000), - tokenDelta: 300, - method: .lifetimeDelta, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: range.end - ) - let zero = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 5_000), - end: Date(timeIntervalSince1970: 6_000), - tokenDelta: 0, - method: .lifetimeDelta, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: range.end - ) - let daily = AccountTokenActivityInterval( - start: Date(timeIntervalSince1970: 7_000), - end: Date(timeIntervalSince1970: 8_000), - tokenDelta: 900, - method: .dailyBuckets, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: nil - ) - let intervals = [daily, zero, positive] - - XCTAssertEqual( - accountTokenInterval( - at: Date(timeIntervalSince1970: 2_500), - in: intervals, - within: range - ), - positive - ) - XCTAssertEqual( - accountTokenInterval( - at: Date(timeIntervalSince1970: 5_500), - in: intervals, - within: range - ), - zero - ) - XCTAssertNil(accountTokenInterval( - at: Date(timeIntervalSince1970: 4_000), - in: intervals, - within: range - )) - XCTAssertNil(accountTokenInterval( - at: Date(timeIntervalSince1970: 9_000), - in: intervals, - within: range - )) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: nil, - by: 1 - ), - positive - ) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: positive, - by: 1 - ), - zero - ) - XCTAssertEqual( - steppedAccountTokenInterval( - in: intervals, - from: zero, - by: 1 - ), - daily - ) - XCTAssertEqual( - retainedAccountTokenInterval( - daily, - in: intervals, - range: range - ), - daily - ) - XCTAssertNil(retainedAccountTokenInterval( - daily, - in: [positive, zero], - range: range - )) - XCTAssertNil(retainedAccountTokenInterval( - daily, - in: intervals, - range: DateInterval(start: range.start, end: daily.start) - )) - XCTAssertEqual(zero.method.displayName, "Lifetime counter interval") - XCTAssertEqual(daily.method.displayName, "UTC daily bucket") - XCTAssertTrue( - accountTokenIntervalAccessibilityValue(zero) - .contains("0 account tokens. Account.") - ) - XCTAssertTrue( - accountTokenIntervalAccessibilityValue(daily) - .contains("UTC daily bucket") - ) - } - - func testSelectedTokenIntervalFormattingDoesNotChangeIdentity() throws { - let formatter = ISO8601DateFormatter() - let interval = AccountTokenActivityInterval( - start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), - end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")), - tokenDelta: 900, - method: .dailyBuckets, - accountPartitionID: "account-a", - limitID: "weekly", - allowanceReset: nil - ) - let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) - let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) - let locale = Locale(identifier: "en_US_POSIX") - let dateInterval = DateInterval(start: interval.start, end: interval.end) - - XCTAssertNotEqual( - accountTokenIntervalText( - dateInterval, - timeZone: utc, - locale: locale - ), - accountTokenIntervalText( - dateInterval, - timeZone: berlin, - locale: locale - ) - ) - XCTAssertEqual(interval.tokenDelta, 900) - XCTAssertEqual(interval.id, interval) - } - func testTokenDisplayAggregationPreservesTotalsGapsAndBreaks() { let base = Date(timeIntervalSince1970: 1_000) let reset = base.addingTimeInterval(20_000) @@ -1566,76 +1425,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testPresentationViewsRenderEveryStateAtLargeAndSmallSizes() { - let presentations: [AnalyticsWorkspacePresentation] = [ - .loading, - .valid, - .stale("Showing the last update."), - .empty, - .sourceError("Couldn’t read Codex usage.") - ] - let sizes = [ - CGSize(width: 390, height: 430), - CGSize(width: 640, height: 780) - ] - - for presentation in presentations { - for size in sizes { - XCTAssertTrue( - renders( - AnalyticsWorkspacePresentationView( - presentation: presentation, - refresh: {} - ) { - Text("Workspace content") - }, - size: size - ), - "\(presentation) did not render at \(size)" - ) - } - } - } - - func testWorkspaceBodyRendersGraphsFactsAndInsights() { - let defaults = UserDefaults( - suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" - )! - let store = AnalyticsWorkspaceStore(defaults: defaults) - let reader = reader(fetchedAt: Date(timeIntervalSince1970: 10_000)) - - for section in AnalyticsSection.allCases { - store.selectSection(section) - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: store, - assistedInsights: CodexAssistedInsightStore() - ), - size: CGSize(width: 640, height: 620) - ), - "\(section.rawValue) did not render" - ) - } - - store.selectSection(.graphs) - for graph in AnalyticsGraph.allCases { - store.selectGraph(graph) - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: store, - assistedInsights: CodexAssistedInsightStore() - ), - size: CGSize(width: 640, height: 620) - ), - "\(graph.rawValue) did not render" - ) - } - } - func testExpiredCurrentWindowRendersUnavailableAndHistoricalUsage() throws { let start = try date("2026-08-01T12:13:00Z") let reset = try date("2026-08-08T12:13:00Z") @@ -1701,48 +1490,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testUsagePerTokenComparisonRendersAtSmallAndLargeSizes() { - let suiteName = - "AnalyticsWorkspaceTests-usage-per-token-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = AnalyticsWorkspaceStore(defaults: defaults) - store.selectTimeRange(.fourWeeks) - let current = comparisonWeek( - index: 5, - movement: 40, - tokens: 10_000_000 - ) - let history = (0 ... 3).map { - comparisonWeek( - index: $0, - movement: Double(($0 + 1) * 10), - tokens: 10_000_000 - ) - } - let snapshot = UsagePerTokenEngine.evaluate( - current: current, - history: history, - pinnedBaselineID: nil - ) - - for size in [ - CGSize(width: 390, height: 430), - CGSize(width: 640, height: 780) - ] { - XCTAssertTrue( - renders( - UsagePerTokenWorkspace( - sourceSnapshot: snapshot, - store: store - ), - size: size - ), - "Usage per token comparison did not render at \(size)" - ) - } - } - private func date(_ value: String) throws -> Date { try XCTUnwrap(ISO8601DateFormatter().date(from: value)) } @@ -1801,42 +1548,4 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - private func comparisonWeek( - index: Int, - movement: Double, - tokens: Int64 - ) -> WeeklyUsageEvidence { - let start = Date(timeIntervalSince1970: 10_000) - .addingTimeInterval(Double(index) * 7 * 86_400) - return WeeklyUsageEvidence( - id: "week-\(index)", - accountPartitionID: "account-a", - limitID: "weekly", - windowDurationMinutes: 10_080, - allowanceResetsAt: start.addingTimeInterval(7 * 86_400), - interval: DateInterval(start: start, duration: 7 * 86_400), - isComplete: index < 5, - accountMovementPoints: movement, - accountTokenActivity: tokens, - localTokenActivity: Int64(Double(tokens) * 0.9), - localCoveragePercent: 90, - boundaryQuality: .tight, - maximumAccountGap: 15 * 60, - modelShares: [ - "gpt-5.6-sol": 0.8, - "gpt-5.6-luna": 0.2 - ], - modelAttributionPercent: 100, - reasoningShares: ["high": 0.8, "medium": 0.2], - reasoningAttributionPercent: 100, - cachedInputShare: 0.4, - containsUnknownCorrection: false, - containsAccountChange: false, - containsCounterDecrease: false, - tokenDefinitionsAlign: true, - localSourceContinuous: true, - localSourceReason: nil - ) - } - } diff --git a/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift b/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift new file mode 100644 index 0000000..90c7f63 --- /dev/null +++ b/Tests/CodexLimitsTests/ClaudeCodeSetupServiceTests.swift @@ -0,0 +1,531 @@ +import ClaudeIntegrationCore +import Combine +import Darwin +import Foundation +import XCTest +@testable import CodexLimits + +final class ClaudeCodeSetupServiceTests: XCTestCase { + func testQAPathsStayInsideTheQABaseAndBundle() { + let base = URL(fileURLWithPath: "/qa-data", isDirectory: true) + let bundle = URL(fileURLWithPath: "/qa-app", isDirectory: true) + + let paths = ClaudeCodeIntegrationPaths.isolatedQA( + base: base, + bundleURL: bundle + ) + + XCTAssertEqual( + paths.executableCandidates, + [bundle.appendingPathComponent( + "Contents/Helpers/CodexLimitsClaudeRelay" + )] + ) + XCTAssertEqual( + paths.settingsURL, + base.appendingPathComponent("Fixtures/ClaudeCode/settings.json") + ) + XCTAssertEqual( + paths.dataDirectory, + base.appendingPathComponent( + "Integrations/ClaudeCode", + isDirectory: true + ) + ) + XCTAssertEqual(paths.helperURL, paths.executableCandidates[0]) + } + + func testSevenDayResetExpiresThePrimaryMetricEvenWhenFiveHourIsStillValid() { + let now = Date(timeIntervalSince1970: 10_000) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(-60), + cliVersion: "2.1.92", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: now.addingTimeInterval(60) + ), + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 40, + resetsAt: now + ) + ) + + XCTAssertEqual(snapshot.displayFreshness(now: now), .expired) + } + + func testFreshnessFallsBackToFiveHourWhenSevenDayIsMissing() { + let now = Date(timeIntervalSince1970: 10_000) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(-31 * 60), + cliVersion: "2.1.92", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: now.addingTimeInterval(60) + ), + sevenDay: nil + ) + + XCTAssertEqual(snapshot.displayFreshness(now: now), .stale) + } + + func testClockRollbackMarksFutureClaudeObservationStale() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(3_600), cliVersion: "2.1.92", fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot(remainingPercent: 70, resetsAt: now.addingTimeInterval(86_400)) + ) + XCTAssertEqual(snapshot.displayFreshness(now: now), .stale) + XCTAssertEqual(snapshot.displayFreshness(now: now.addingTimeInterval(3_540)), .fresh) + XCTAssertEqual(snapshot.displayFreshness(now: now.addingTimeInterval(3_660)), .fresh) + } + + @MainActor + func testDisableSuppressesAQueuedReadinessResult() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + let coordinator = IntegrationWorkCoordinator() + let gate = ClaudeCoordinatorGate() + let blocker = Task { + await coordinator.run(priority: .explicit) { + await gate.hold() + } + } + while !(await gate.started) { + await Task.yield() + } + let store = ClaudeCodeIntegrationStore( + isEnabled: false, + service: service, + integrationWorkCoordinator: coordinator + ) + var readinessValues: [ClaudeCodeReadiness] = [] + let observation = store.$readiness.sink { + readinessValues.append($0) + } + defer { observation.cancel() } + + let enabling = Task { @MainActor in + await store.setEnabled(true) + } + while store.readiness != .checking { + await Task.yield() + } + let disablingStarted = expectation(description: "Disable requested") + let disabling = Task { @MainActor in + disablingStarted.fulfill() + await store.setEnabled(false) + } + await fulfillment(of: [disablingStarted], timeout: 2) + await gate.release() + await blocker.value + await enabling.value + await disabling.value + + XCTAssertEqual(store.readiness, .disabled) + XCTAssertFalse(readinessValues.contains(.setUp)) + } + + @MainActor + func testUnselectedHiddenClaudeDefersCacheReadUntilVisible() async throws { + let fixture = try fixture(settings: [:]) + try FileManager.default.createDirectory( + at: fixture.paths.dataDirectory, + withIntermediateDirectories: true + ) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 80, + resetsAt: Date().addingTimeInterval(3_600) + ), + sevenDay: nil + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: ClaudeCodeSetupService(paths: fixture.paths) + ) + + try await Task.sleep(for: .milliseconds(20)) + XCTAssertNil(store.snapshot) + XCTAssertEqual(store.readiness, .checking) + + await store.setVisible(true) + + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.readiness, .setUp) + } + + @MainActor + func testHidingClaudeSuppressesQueuedVisibleRead() async throws { + let fixture = try fixture(settings: [:]) + let coordinator = IntegrationWorkCoordinator() + let gate = ClaudeCoordinatorGate() + let blocker = Task { + await coordinator.run(priority: .explicit) { + await gate.hold() + } + } + while !(await gate.started) { + await Task.yield() + } + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: ClaudeCodeSetupService(paths: fixture.paths), + integrationWorkCoordinator: coordinator + ) + let showing = Task { @MainActor in + await store.setVisible(true) + } + try await Task.sleep(for: .milliseconds(10)) + + await store.setVisible(false) + await gate.release() + await blocker.value + await showing.value + XCTAssertEqual(store.readiness, .checking) + + await store.setVisible(true) + XCTAssertEqual(store.readiness, .setUp) + } + + func testSetupPreservesSettingsAndExactDeactivateRemovesOnlyOwnedStatusLine() async throws { + let fixture = try fixture(settings: ["model": "sonnet"]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + let installed = try await service.setUp() + XCTAssertEqual(installed.readiness, .waitingForData) + var settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNotNil(settings["statusLine"]) + + let didDeactivate = await service.deactivate() + XCTAssertTrue(didDeactivate) + settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + } + + func testDeactivateRemovesASettingsFileCreatedBySetup() async throws { + let fixture = try fixture(settings: nil) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + _ = try await service.setUp() + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.paths.settingsURL.path + )) + + let didDeactivate = await service.deactivate() + + XCTAssertTrue(didDeactivate) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.paths.settingsURL.path + )) + } + + func testDeactivateKeepsSettingsAddedAfterSetupCreatedTheFile() async throws { + let fixture = try fixture(settings: nil) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + var settings = try settingsObject(at: fixture.paths.settingsURL) + settings["model"] = "sonnet" + try JSONSerialization.data(withJSONObject: settings).write( + to: fixture.paths.settingsURL, + options: .atomic + ) + + let didDeactivate = await service.deactivate() + + XCTAssertTrue(didDeactivate) + settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + } + + func testSetupRefusesUserOwnedStatusLineWithoutChangingIt() async throws { + let owned: [String: Any] = [ + "type": "command", + "command": "user-status", + "padding": 2 + ] + let fixture = try fixture(settings: ["statusLine": owned]) + let before = try Data(contentsOf: fixture.paths.settingsURL) + let service = ClaudeCodeSetupService(paths: fixture.paths) + + let inspection = await service.inspect() + XCTAssertEqual(inspection.readiness, .conflict) + do { + _ = try await service.setUp() + XCTFail("Expected a status-line conflict") + } catch ClaudeCodeSetupService.SetupError.conflict { + } + + XCTAssertEqual(try Data(contentsOf: fixture.paths.settingsURL), before) + } + + func testDeactivateLeavesModifiedOwnedConfigurationAndStopsWrites() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + var settings = try settingsObject(at: fixture.paths.settingsURL) + var statusLine = try XCTUnwrap(settings["statusLine"] as? [String: Any]) + statusLine["padding"] = 1 + settings["statusLine"] = statusLine + try JSONSerialization.data(withJSONObject: settings).write( + to: fixture.paths.settingsURL, + options: .atomic + ) + + let didDeactivate = await service.deactivate() + XCTAssertFalse(didDeactivate) + XCTAssertNotNil( + try settingsObject(at: fixture.paths.settingsURL)["statusLine"] + ) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.paths.enabledMarkerURL.path + )) + } + + func testDeleteDataRemovesOnlyOwnedSettingsAndFiles() async throws { + let fixture = try fixture(settings: ["model": "sonnet"]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date().addingTimeInterval(3_600) + ) + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + let readyInspection = await service.inspect() + XCTAssertEqual(readyInspection.readiness, .ready) + + let deleted = await service.deleteData() + XCTAssertTrue(deleted) + let settings = try settingsObject(at: fixture.paths.settingsURL) + XCTAssertEqual(settings["model"] as? String, "sonnet") + XCTAssertNil(settings["statusLine"]) + let hasStoredData = await service.hasStoredData() + XCTAssertFalse(hasStoredData) + } + + func testDeletionWaitsForAnActiveRelayAndKeepsItsLockInode() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let lockURL = fixture.paths.cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let inode = try FileManager.default.attributesOfItem(atPath: lockURL.path)[.systemFileNumber] as? NSNumber + let deleting = Task { await service.deleteData() } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path), + ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + + // A relay past the marker check can still finish its write under this lock. + try Data("in-flight snapshot".utf8).write(to: fixture.paths.cacheURL) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + let deleted = await deleting.value + + XCTAssertTrue(deleted) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.cacheURL.path)) + XCTAssertEqual( + try FileManager.default.attributesOfItem(atPath: lockURL.path)[.systemFileNumber] as? NSNumber, + inode + ) + } + + func testSetupSupersedesDeletionWaitingForARelay() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let lockURL = fixture.paths.cacheURL.appendingPathExtension("lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + let deleting = Task { await service.deleteData() } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + while FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path), + ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + + _ = try await service.setUp() + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + let deleted = await deleting.value + + XCTAssertFalse(deleted) + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.paths.enabledMarkerURL.path)) + XCTAssertNotNil(try settingsObject(at: fixture.paths.settingsURL)["statusLine"]) + } + + @MainActor + func testReadFailureAndMissingCLIPreserveTheLastSnapshot() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService(paths: fixture.paths) + _ = try await service.setUp() + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date().addingTimeInterval(3_600) + ) + ) + let data = try JSONEncoder().encode(snapshot) + try data.write(to: fixture.paths.cacheURL) + let store = ClaudeCodeIntegrationStore( + isEnabled: true, + menuBarSourceActive: false, + service: service + ) + await store.setVisible(true) + XCTAssertEqual(store.displayFreshness, .fresh) + + try Data("invalid cache".utf8).write(to: fixture.paths.cacheURL) + await store.checkForNewObservation() + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.readiness, .failed) + XCTAssertEqual(store.displayFreshness, .stale) + await store.settingsPresented() + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.displayFreshness, .stale) + + try data.write(to: fixture.paths.cacheURL) + await store.checkForNewObservation() + XCTAssertEqual(store.readiness, .ready) + XCTAssertEqual(store.displayFreshness, .fresh) + + try FileManager.default.removeItem(at: fixture.paths.executableCandidates[0]) + await store.settingsPresented() + XCTAssertEqual(store.readiness, .notFound) + XCTAssertEqual(store.snapshot, snapshot) + XCTAssertEqual(store.displayFreshness, .stale) + } + + func testSelectedExecutableMustBeARegularExecutableFile() async throws { + let fixture = try fixture(settings: [:]) + let service = ClaudeCodeSetupService( + paths: ClaudeCodeIntegrationPaths( + executableCandidates: [], + settingsURL: fixture.paths.settingsURL, + dataDirectory: fixture.paths.dataDirectory, + helperURL: fixture.paths.helperURL + ) + ) + let plainFile = fixture.root.appendingPathComponent("not-executable") + try Data().write(to: plainFile) + + let missingInspection = await service.inspect() + XCTAssertEqual(missingInspection.readiness, .notFound) + let rejected = await service.selectExecutable(plainFile) + XCTAssertNil(rejected) + let inspection = await service.selectExecutable( + fixture.paths.executableCandidates[0] + ) + XCTAssertEqual(inspection?.readiness, .setUp) + } + + func testUpdateRequiredPreservesACompatibleSnapshot() async throws { + let fixture = try fixture(settings: [:]) + try FileManager.default.createDirectory( + at: fixture.paths.dataDirectory, + withIntermediateDirectories: true + ) + let snapshot = ClaudeAllowanceSnapshot( + observedAt: Date(), + cliVersion: "2.1.231", + fiveHour: ClaudeAllowanceWindowSnapshot( + remainingPercent: 75, + resetsAt: Date().addingTimeInterval(3_600) + ), + sevenDay: nil + ) + try JSONEncoder().encode(snapshot).write(to: fixture.paths.cacheURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: fixture.paths.helperURL.path + ) + + let inspection = await ClaudeCodeSetupService( + paths: fixture.paths + ).inspect() + + XCTAssertEqual(inspection.readiness, .updateRequired) + XCTAssertEqual(inspection.snapshot, snapshot) + } + + private func fixture( + settings: [String: Any]? + ) throws -> (paths: ClaudeCodeIntegrationPaths, root: URL) { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let executable = root.appendingPathComponent("claude") + let helper = root.appendingPathComponent("relay") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try Data("#!/bin/sh\n".utf8).write(to: helper) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: helper.path + ) + let settingsURL = root.appendingPathComponent("settings.json") + if let settings { + try JSONSerialization.data(withJSONObject: settings).write( + to: settingsURL + ) + } + return ( + ClaudeCodeIntegrationPaths( + executableCandidates: [executable], + settingsURL: settingsURL, + dataDirectory: root.appendingPathComponent("data", isDirectory: true), + helperURL: helper + ), + root + ) + } + + private func settingsObject(at url: URL) throws -> [String: Any] { + try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(contentsOf: url) + ) as? [String: Any] + ) + } +} + +private actor ClaudeCoordinatorGate { + private(set) var started = false + private var continuation: CheckedContinuation? + + func hold() async { + started = true + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/CodexLimitsTests/ClaudeRelayTests.swift b/Tests/CodexLimitsTests/ClaudeRelayTests.swift new file mode 100644 index 0000000..7502f18 --- /dev/null +++ b/Tests/CodexLimitsTests/ClaudeRelayTests.swift @@ -0,0 +1,214 @@ +import ClaudeIntegrationCore +import XCTest + +final class ClaudeRelayTests: XCTestCase { + func testBoundedReaderConsumesMultipleChunksAndStopsAtTheCap() throws { + let root = temporaryDirectory() + let inputURL = root.appendingPathComponent("input.json") + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + var input = Data(repeating: 0x20, count: 70 * 1_024) + input.append(Data( + #"{"rate_limits":{"seven_day":{"used_percentage":40,"resets_at":2200000}}}"#.utf8 + )) + try input.write(to: inputURL) + + var handle = try FileHandle(forReadingFrom: inputURL) + var read = try ClaudeRelay.readBoundedInput(from: handle) + try handle.close() + XCTAssertEqual(read, input) + XCTAssertNoThrow(try ClaudeRelay.decode(read, observedAt: Date())) + + try Data( + repeating: 0x20, + count: ClaudeRelay.maximumInputBytes + 100 + ).write(to: inputURL) + handle = try FileHandle(forReadingFrom: inputURL) + read = try ClaudeRelay.readBoundedInput(from: handle) + try handle.close() + XCTAssertEqual(read.count, ClaudeRelay.maximumInputBytes + 1) + } + + func testUnavailableStatusLineIsUsefulAndNeutral() { + XCTAssertEqual(ClaudeRelay.unavailableStatusLine, "Usage unavailable") + } + + func testDecodesOnlyBoundedAllowanceWindows() throws { + let observedAt = Date(timeIntervalSince1970: 2_000_000) + let snapshot = try ClaudeRelay.decode( + Data(#"{"version":"2.1.92","session_id":"private","model":{"display_name":"Private"},"rate_limits":{"five_hour":{"used_percentage":25,"resets_at":2100000},"seven_day":{"used_percentage":40,"resets_at":2200000}}}"#.utf8), + observedAt: observedAt + ) + + XCTAssertEqual(snapshot.observedAt, observedAt) + XCTAssertEqual(snapshot.cliVersion, "2.1.92") + XCTAssertEqual(snapshot.fiveHour?.remainingPercent, 75) + XCTAssertEqual(snapshot.sevenDay?.remainingPercent, 60) + XCTAssertEqual( + ClaudeRelay.statusLine(for: snapshot), + "7d 60% remaining · 5h 75% remaining" + ) + let encoded = String( + data: try JSONEncoder().encode(snapshot), + encoding: .utf8 + )! + XCTAssertFalse(encoded.contains("session")) + XCTAssertFalse(encoded.contains("model")) + XCTAssertFalse(encoded.contains("Private")) + } + + func testRejectsInvalidOrUnboundedInput() { + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(#"{"rate_limits":{"seven_day":{"used_percentage":101,"resets_at":2200000}}}"#.utf8), + observedAt: Date() + )) + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(repeating: 0x20, count: ClaudeRelay.maximumInputBytes + 1), + observedAt: Date() + )) + XCTAssertThrowsError(try ClaudeRelay.decode( + Data(#"{"rate_limits":{}}"#.utf8), + observedAt: Date() + )) + } + + func testOlderWriterCannotReplaceNewerSnapshot() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + let marker = root.appendingPathComponent("enabled") + try Data().write(to: marker) + let newer = snapshot(observedAt: 200, remaining: 60) + let older = snapshot(observedAt: 100, remaining: 80) + + _ = try ClaudeRelay.storeIfNewer( + newer, + at: cache, + enabledMarkerURL: marker + ) + _ = try ClaudeRelay.storeIfNewer( + older, + at: cache, + enabledMarkerURL: marker + ) + + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache), newer) + } + + func testEquivalentObservationWithinThirtySecondsDoesNotRewriteCache() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + let marker = root.appendingPathComponent("enabled") + try Data().write(to: marker) + let first = snapshot(observedAt: 100, remaining: 80) + let equivalent = snapshot(observedAt: 110, remaining: 80) + let changed = snapshot(observedAt: 111, remaining: 79) + + XCTAssertTrue(try ClaudeRelay.storeIfNewer( + first, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertFalse(try ClaudeRelay.storeIfNewer( + equivalent, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertTrue(try ClaudeRelay.storeIfNewer( + changed, + at: cache, + enabledMarkerURL: marker + )) + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache), changed) + } + + func testClockRollbackReplacesFutureCacheWithoutSeedingItAndKeepsWriterOrdering() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let cache = root.appendingPathComponent("snapshot.json") + let marker = root.appendingPathComponent("enabled") + try Data().write(to: marker) + let now = Date(timeIntervalSince1970: 1_800_000_000) + func reading(offset: TimeInterval, remaining: Double) -> ClaudeAllowanceSnapshot { + ClaudeAllowanceSnapshot( + observedAt: now.addingTimeInterval(offset), cliVersion: "2.1.92", fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot(remainingPercent: remaining, resetsAt: now.addingTimeInterval(86_400)) + ) + } + let future = reading(offset: 3_600, remaining: 90) + try JSONEncoder().encode(future).write(to: cache) + XCTAssertThrowsError(try ClaudeRelay.readSnapshot(at: cache, now: now)) + let current = reading(offset: 0, remaining: 70) + + XCTAssertTrue(try ClaudeRelay.storeIfNewer(current, at: cache, enabledMarkerURL: marker, now: { now })) + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache, now: now), current) + let history = try AllowanceHistory.read(in: ClaudeRelay.historyDirectory(for: cache), now: now.addingTimeInterval(7_200)) + XCTAssertEqual(history, current.historyObservations, "Rejected future cache must not seed the history") + + let delayed = reading(offset: -120, remaining: 80) + XCTAssertFalse(try ClaudeRelay.storeIfNewer(delayed, at: cache, enabledMarkerURL: marker, now: { now.addingTimeInterval(180) })) + XCTAssertThrowsError(try ClaudeRelay.storeIfNewer(future, at: cache, enabledMarkerURL: marker, now: { now })) + XCTAssertEqual(try ClaudeRelay.readSnapshot(at: cache, now: now), current) + } + + func testReaderRejectsTamperedCache() throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + let cache = root.appendingPathComponent("snapshot.json") + try JSONEncoder().encode( + snapshot(observedAt: 100, remaining: 200) + ).write(to: cache) + + XCTAssertThrowsError(try ClaudeRelay.readSnapshot(at: cache)) + + try JSONEncoder().encode(ClaudeAllowanceSnapshot( + observedAt: Date(timeIntervalSince1970: 100), + cliVersion: "private\nvalue", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: 50, + resetsAt: Date(timeIntervalSince1970: 1_000) + ) + )).write(to: cache) + XCTAssertThrowsError(try ClaudeRelay.readSnapshot(at: cache)) + } + + func testMissingEnabledMarkerPreventsPersistence() { + let root = temporaryDirectory() + let cache = root.appendingPathComponent("snapshot.json") + + XCTAssertThrowsError(try ClaudeRelay.storeIfNewer( + snapshot(observedAt: 100, remaining: 80), + at: cache, + enabledMarkerURL: root.appendingPathComponent("missing") + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + } + + private func snapshot( + observedAt: TimeInterval, + remaining: Double + ) -> ClaudeAllowanceSnapshot { + ClaudeAllowanceSnapshot( + observedAt: Date(timeIntervalSince1970: observedAt), + cliVersion: "2.1.92", + fiveHour: nil, + sevenDay: ClaudeAllowanceWindowSnapshot( + remainingPercent: remaining, + resetsAt: Date(timeIntervalSince1970: 1_000) + ) + ) + } +} diff --git a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift index d978441..6c3842e 100644 --- a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift +++ b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift @@ -1,5 +1,3 @@ -import AppKit -import SwiftUI import XCTest @testable import CodexLimits @@ -379,8 +377,6 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertFalse(failedStore.showsAnalyzeAction) XCTAssertEqual(missingCalls.analysisCalls, 0) XCTAssertEqual(failedCalls.analysisCalls, 0) - XCTAssertFalse(missingStore.showsCard) - XCTAssertFalse(failedStore.showsCard) } func testCancelledAvailabilityCheckCanRunAgain() async { @@ -401,7 +397,6 @@ final class CodexAssistedInsightTests: XCTestCase { let calls = await service.snapshot() XCTAssertEqual(calls.catalogCalls, 2) XCTAssertTrue(store.showsAnalyzeAction) - XCTAssertTrue(store.showsCard) } func testExplicitAnalysisClickPublishesMarkedResultAndOverhead() async { @@ -562,7 +557,6 @@ final class CodexAssistedInsightTests: XCTestCase { let calls = await service.snapshot() XCTAssertEqual(calls.catalogCalls, 2) XCTAssertFalse(store.showsAnalyzeAction) - XCTAssertFalse(store.showsCard) XCTAssertNil(store.result) } @@ -1367,108 +1361,6 @@ final class CodexAssistedInsightTests: XCTestCase { XCTAssertEqual(fixture.snapshot().turnStartCount, 0) } - func testActionProgressFailureAndResultRenderAtSmallAndLargeSizes() async { - let reader = UsageIntelligenceEngine.evaluate( - UsageIntelligenceInput( - account: nil, - samples: [], - safetyBuffer: 3, - sourceState: .available, - now: Date(timeIntervalSince1970: 2_000), - previousStatus: nil - ) - ) - let defaults = UserDefaults( - suiteName: "CodexAssistedInsightTests-\(UUID().uuidString)" - )! - let workspace = AnalyticsWorkspaceStore(defaults: defaults) - workspace.selectSection(.insights) - - let successService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .succeeded(analysisResult()) - ) - let successStore = CodexAssistedInsightStore(service: successService) - await successStore.checkAvailability() - for size in [ - CGSize(width: 420, height: 620), - CGSize(width: 720, height: 780) - ] { - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: successStore - ), - size: size - ) - ) - } - - successStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await successStore.waitForAnalysis() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: successStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - - let delayedService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .delayed - ) - let delayedStore = CodexAssistedInsightStore(service: delayedService) - await delayedStore.checkAvailability() - delayedStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await Task.yield() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: delayedStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - await delayedStore.cancelAnalysis() - await delayedStore.waitForAnalysis() - - let failedService = AssistedServiceFixture( - catalogResult: .success(eligibleProfile()), - analysisResult: .failed(failedOverhead()) - ) - let failedStore = CodexAssistedInsightStore(service: failedService) - await failedStore.checkAvailability() - failedStore.startAnalysis( - payload: metadataPayload(), - scope: analysisScope() - ) - await failedStore.waitForAnalysis() - XCTAssertTrue( - renders( - AnalyticsWorkspaceBody( - reader: reader, - store: workspace, - assistedInsights: failedStore - ), - size: CGSize(width: 520, height: 720) - ) - ) - } - private func profile( id: String, model: String? = nil, @@ -1687,17 +1579,6 @@ final class CodexAssistedInsightTests: XCTestCase { try XCTUnwrap(request["params"] as? [String: Any]) } - private func renders( - _ view: V, - size: CGSize - ) -> Bool { - let renderer = ImageRenderer( - content: view.frame(width: size.width, height: size.height) - ) - renderer.proposedSize = ProposedViewSize(size) - return renderer.nsImage != nil - } - private func waitUntil( timeout: Duration = .seconds(1), condition: @escaping @Sendable () async -> Bool diff --git a/Tests/CodexLimitsTests/CodexClientTests.swift b/Tests/CodexLimitsTests/CodexClientTests.swift index b86bd38..db5c984 100644 --- a/Tests/CodexLimitsTests/CodexClientTests.swift +++ b/Tests/CodexLimitsTests/CodexClientTests.swift @@ -2,6 +2,30 @@ import XCTest @testable import CodexLimits final class CodexClientTests: XCTestCase { + func testSelectedExecutableMustBeARegularExecutableFile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + _ = CodexClient.selectExecutable(nil) + try? FileManager.default.removeItem(at: directory) + } + let executable = directory.appendingPathComponent("codex") + let plainFile = directory.appendingPathComponent("plain") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try Data().write(to: plainFile) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: executable.path + ) + + XCTAssertFalse(CodexClient.selectExecutable(plainFile)) + XCTAssertTrue(CodexClient.selectExecutable(executable)) + } + func testIsolatedHomeLinksCredentialsAndRemovesTheLink() throws { let root = temporaryDirectory() let source = root.appendingPathComponent("source", isDirectory: true) @@ -112,6 +136,22 @@ final class CodexClientTests: XCTestCase { XCTAssertEqual(server.initializationCount, 1) } + func testIdleConnectionClosesAndTheNextReadReconnects() async throws { + let server = PersistentAppServerFixture() + let client = CodexClient( + makeConnection: server.makeConnection, + timeout: 1, + connectionIdleTimeout: 0.01 + ) + + _ = try await client.installedCLIVersion() + try await Task.sleep(nanoseconds: 30_000_000) + _ = try await client.installedCLIVersion() + + XCTAssertEqual(server.connectionCount, 2) + XCTAssertEqual(server.initializationCount, 2) + } + func testClosedServerOutputReportsConnectionLost() async { let client = CodexClient( makeConnection: { diff --git a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift index 54844e9..f1a4bdd 100644 --- a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift +++ b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift @@ -1,5 +1,4 @@ import Foundation -import SwiftUI import XCTest @testable import CodexLimits @@ -495,7 +494,7 @@ final class CodexSourceAnalysisTests: XCTestCase { let preparation = Task { await store.prepareSourceAnalysis(selection: selection) } - await Task.yield() + while !(await reader.isPreparing) { await Task.yield() } store.invalidateSourcePreflight( for: CodexSourceSelection( interval: DateInterval( @@ -507,6 +506,7 @@ final class CodexSourceAnalysisTests: XCTestCase { projectLabel: selection.projectLabel ) ) + await reader.resume() await preparation.value XCTAssertNil(store.sourcePreflight) @@ -530,7 +530,7 @@ final class CodexSourceAnalysisTests: XCTestCase { let preparation = Task { await store.prepareSourceAnalysis(selection: selection) } - try? await Task.sleep(for: .milliseconds(20)) + while !(await reader.isPreparing) { await Task.yield() } store.cancelSourcePreflight() await preparation.value @@ -750,7 +750,7 @@ final class CodexSourceAnalysisTests: XCTestCase { eligibleProfile(effort: "medium"), eligibleProfile(effort: "medium") ], - delayedPrimaryCalls: [2] + heldPrimaryCalls: [2] ) let store = CodexAssistedInsightStore( service: service, @@ -774,8 +774,9 @@ final class CodexSourceAnalysisTests: XCTestCase { categories: [.prompts] ) } - try? await Task.sleep(for: .milliseconds(20)) + while !(await service.isPrimaryCheckHeld) { await Task.yield() } store.invalidateSourcePreflight(for: changed(selection)) + await service.resumePrimaryCheck() let started = await start.value let calls = await service.sourceCalls() @@ -792,7 +793,7 @@ final class CodexSourceAnalysisTests: XCTestCase { eligibleProfile(effort: "medium") ], strongerProfile: eligibleProfile(effort: "high"), - delayedPrimaryCalls: [3] + heldPrimaryCalls: [3] ) let store = CodexAssistedInsightStore( service: service, @@ -818,8 +819,9 @@ final class CodexSourceAnalysisTests: XCTestCase { let retry = Task { await store.retrySourceWithStrongerProfile() } - try? await Task.sleep(for: .milliseconds(20)) + while !(await service.isPrimaryCheckHeld) { await Task.yield() } store.invalidateSourcePreflight(for: changed(selection)) + await service.resumePrimaryCheck() let retried = await retry.value let calls = await service.sourceCalls() @@ -883,33 +885,6 @@ final class CodexSourceAnalysisTests: XCTestCase { XCTAssertNil(properties["insightKind"]) } - func testPreflightRendersWithNativeCategoryControls() { - let draft = CodexSourceContentDraft( - selection: sourceSelection(), - values: [ - .prompts: ["Build the report"], - .responses: ["Done"], - .code: ["+let answer = 42"], - .paths: ["/synthetic/atlas/App.swift"], - .commands: ["swift test"], - .toolOutput: ["All tests passed"] - ] - ) - let renderer = ImageRenderer( - content: SourceAnalysisPreflightView( - draft: draft, - cancel: {}, - analyze: { _ in } - ) - ) - renderer.proposedSize = ProposedViewSize( - width: 460, - height: 640 - ) - - XCTAssertNotNil(renderer.nsImage) - } - func testSourceContentNeverEntersAnalyticsHistory() async throws { let fileURL = temporaryDirectory() .appendingPathComponent("history.json") @@ -1247,6 +1222,8 @@ private actor SourceReaderFixture: CodexSourceContentReading { private actor DelayedSourceReaderFixture: CodexSourceContentReading { let draft: CodexSourceContentDraft + private var continuation: CheckedContinuation? + var isPreparing: Bool { continuation != nil } init(draft: CodexSourceContentDraft) { self.draft = draft @@ -1255,14 +1232,21 @@ private actor DelayedSourceReaderFixture: CodexSourceContentReading { func prepare( selection _: CodexSourceSelection ) async throws -> CodexSourceContentDraft { - try await Task.sleep(for: .milliseconds(30)) + await withCheckedContinuation { continuation = $0 } return draft } + + func resume() { + continuation?.resume() + continuation = nil + } } private actor CancellableSourceReaderFixture: CodexSourceContentReading { let draft: CodexSourceContentDraft private var cancelled = false + private var continuation: CheckedContinuation? + var isPreparing: Bool { continuation != nil } init(draft: CodexSourceContentDraft) { self.draft = draft @@ -1272,7 +1256,11 @@ private actor CancellableSourceReaderFixture: CodexSourceContentReading { selection _: CodexSourceSelection ) async throws -> CodexSourceContentDraft { do { - try await Task.sleep(for: .seconds(1)) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation = $0 } + } onCancel: { + Task { await self.cancel() } + } return draft } catch { cancelled = true @@ -1283,6 +1271,11 @@ private actor CancellableSourceReaderFixture: CodexSourceContentReading { func wasCancelled() -> Bool { cancelled } + + private func cancel() { + continuation?.resume(throwing: CancellationError()) + continuation = nil + } } private actor SourceAnalysisServiceFixture: CodexAssistedInsightServicing { @@ -1295,8 +1288,10 @@ private actor SourceAnalysisServiceFixture: CodexAssistedInsightServicing { private var primaryProfiles: [CodexAssistedModelProfile?] private let advertisedStrongerProfile: CodexAssistedModelProfile? private let sourceOutcome: CodexAssistedAnalysisOutcome - private let delayedPrimaryCalls: Set + private let heldPrimaryCalls: Set private var primaryCallCount = 0 + private var primaryCheck: CheckedContinuation? + var isPrimaryCheckHeld: Bool { primaryCheck != nil } init( primaryProfiles: [CodexAssistedModelProfile?] = [ @@ -1307,7 +1302,7 @@ private actor SourceAnalysisServiceFixture: CodexAssistedInsightServicing { ) ], strongerProfile: CodexAssistedModelProfile? = nil, - delayedPrimaryCalls: Set = [], + heldPrimaryCalls: Set = [], sourceOutcome: CodexAssistedAnalysisOutcome = .failed( CodexAnalyticsOverhead( durationSeconds: 0, @@ -1317,14 +1312,14 @@ private actor SourceAnalysisServiceFixture: CodexAssistedInsightServicing { ) { self.primaryProfiles = primaryProfiles advertisedStrongerProfile = strongerProfile - self.delayedPrimaryCalls = delayedPrimaryCalls + self.heldPrimaryCalls = heldPrimaryCalls self.sourceOutcome = sourceOutcome } func eligibleProfile() async throws -> CodexAssistedModelProfile? { primaryCallCount += 1 - if delayedPrimaryCalls.contains(primaryCallCount) { - try await Task.sleep(for: .milliseconds(80)) + if heldPrimaryCalls.contains(primaryCallCount) { + await withCheckedContinuation { primaryCheck = $0 } } guard !primaryProfiles.isEmpty else { return nil } return primaryProfiles.count == 1 @@ -1336,6 +1331,11 @@ private actor SourceAnalysisServiceFixture: CodexAssistedInsightServicing { advertisedStrongerProfile } + func resumePrimaryCheck() { + primaryCheck?.resume() + primaryCheck = nil + } + func analyze( payload _: CodexMetadataAnalysisPayload, profile _: CodexAssistedModelProfile diff --git a/Tests/CodexLimitsTests/GrokBillingClientTests.swift b/Tests/CodexLimitsTests/GrokBillingClientTests.swift new file mode 100644 index 0000000..ac5b64b --- /dev/null +++ b/Tests/CodexLimitsTests/GrokBillingClientTests.swift @@ -0,0 +1,226 @@ +import Darwin +import XCTest +@testable import CodexLimits + +final class GrokBillingClientTests: XCTestCase { + private let observedAt = Date(timeIntervalSince1970: 1_700_000_000) + private let current = #"{"config":{"creditUsagePercent":25.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2030-01-01T00:00:00+00:00","end":"2030-01-08T00:00:00.123456+00:00"},"prepaidBalance":{},"onDemandUsed":{"val":125},"onDemandCap":{"val":2500},"isUnifiedBillingUser":true},"subscription_tier":"Super\u0007Grok","private":"do-not-retain"}"# + + func testCurrentAndLegacyAllowancesKeepOnlyValidatedFacts() throws { + let decoded = try decode(current) + XCTAssertEqual(decoded.remainingPercent, 74.5) + XCTAssertEqual(decoded.period, .weekly) + XCTAssertEqual(decoded.subscriptionTier, "SuperGrok") + XCTAssertEqual(decoded.prepaidBalanceUSD, 0) + XCTAssertEqual(decoded.onDemandUsedUSD, 1.25) + XCTAssertEqual(decoded.onDemandCapUSD, 25) + XCTAssertEqual(decoded.isUnifiedBilling, true) + XCTAssertTrue(decoded.isValid) + XCTAssertEqual(decoded.measurementSource, "creditUsagePercent") + XCTAssertEqual(try XCTUnwrap(decoded.startsAt).timeIntervalSince1970, 1_893_456_000) + XCTAssertEqual(decoded.resetsAt.timeIntervalSince1970, 1_894_060_800.123456, accuracy: 0.001) + let encoded = try JSONEncoder().encode(decoded) + XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains("do-not-retain")) + XCTAssertEqual(try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: encoded), decoded) + + let legacy = try decode(#"{"config":{"monthlyLimit":{"val":10000},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#) + XCTAssertEqual(legacy.period, .monthly) + XCTAssertEqual(legacy.remainingPercent, 100) + XCTAssertEqual(legacy.measurementSource, "legacyCredits") + XCTAssertNil(legacy.prepaidBalanceUSD) + XCTAssertNil(legacy.startsAt) + let above = try decode(current.replacingOccurrences(of: "25.5", with: "120")) + XCTAssertEqual(above.reportedUsedPercent, 120) + XCTAssertEqual(above.remainingPercent, 0) + XCTAssertTrue(above.isValid) + let monthly = try decode(current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_MONTHLY")) + XCTAssertEqual(monthly.period, .monthly) + } + + func testPeriodStartIsRetainedWhenSuppliedAndNeverInferredWhenMissing() throws { + let legacy = try decode(#"{"config":{"monthlyLimit":{"val":10000},"used":{"val":100},"billingPeriodStart":"2030-01-03T12:00:00+02:00","billingPeriodEnd":"2030-02-03T10:00:00Z"}}"#) + XCTAssertEqual(try XCTUnwrap(legacy.startsAt).timeIntervalSince1970, 1_893_664_800) + XCTAssertTrue(legacy.isValid) + + let monthly = current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_MONTHLY") + for fixture in [ + monthly.replacingOccurrences(of: #""start":"2030-01-01T00:00:00+00:00","#, with: ""), + monthly.replacingOccurrences(of: #""start":"2030-01-01T00:00:00+00:00""#, with: #""start":null"#) + ] { + let snapshot = try decode(fixture) + XCTAssertNil(snapshot.startsAt) + XCTAssertEqual(snapshot.period, .monthly) + XCTAssertTrue(snapshot.isValid) + } + } + + func testOldLatestSnapshotDecodesWithoutInventingStartAndCorruptDatesAreInvalid() throws { + let original = try decode(current) + var saved = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(original)) as? [String: Any]) + saved.removeValue(forKey: "startsAt") + let migrated = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: saved)) + XCTAssertNil(migrated.startsAt) + XCTAssertEqual(migrated.remainingPercent, original.remainingPercent) + XCTAssertEqual(migrated.observedAt, original.observedAt) + XCTAssertEqual(migrated.resetsAt, original.resetsAt) + XCTAssertTrue(migrated.isValid) + + for (key, invalidDate) in [ + ("startsAt", original.resetsAt), + ("startsAt", Date.distantPast.addingTimeInterval(-1)), + ("resetsAt", Date.distantFuture.addingTimeInterval(1)), + ("observedAt", Date.distantFuture.addingTimeInterval(1)) + ] { + var corrupted = saved + corrupted[key] = invalidDate.timeIntervalSinceReferenceDate + let snapshot = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: corrupted)) + XCTAssertFalse(snapshot.isValid, key) + } + } + + func testInvalidCurrentFieldsNeverFallBackToLegacyOrInventZero() throws { + let fixtures: [(String, GrokBillingError)] = [ + (#"{"config":null}"#, .missingAllowance), + (#"{"config":{"monthlyLimit":{},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#, .missingAllowance), + (#"{"config":{"creditUsagePercent":null,"monthlyLimit":{"val":100},"used":{},"billingPeriodEnd":"2030-02-01T00:00:00Z"}}"#, .missingAllowance), + (current.replacingOccurrences(of: "25.5", with: "true"), .missingAllowance), + (current.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_DAILY"), .unknownPeriod), + (current.replacingOccurrences(of: "2030-01-08T00:00:00.123456+00:00", with: "bad"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "2030-02-01T00:00:00Z"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "2030-01-08T00:00:00.123456+00:00"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-01T00:00:00+00:00", with: "bad"), .invalidReset), + (current.replacingOccurrences(of: "2030-01-08T00:00:00.123456+00:00", with: "5000-01-08T00:00:00Z"), .invalidReset) + ] + for (fixture, error) in fixtures { + XCTAssertThrowsError(try decode(fixture)) { + XCTAssertEqual($0 as? GrokBillingError, error) + } + } + var saved = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(decode(current))) as? [String: Any]) + saved["subscriptionTier"] = "bad\u{001b}text" + let corrupted = try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONSerialization.data(withJSONObject: saved)) + XCTAssertFalse(corrupted.isValid) + XCTAssertThrowsError(try GrokAllowanceSnapshot.decode( + Data(current.utf8), observedAt: .distantFuture.addingTimeInterval(1), sourceVersion: nil + )) { + XCTAssertEqual($0 as? GrokBillingError, .invalidResponse) + } + } + + func testTransportUsesPrefixedBillingAfterInitializeAndCleansItsWorkingDirectory() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let record = directory.appendingPathComponent("requests") + let cwd = directory.appendingPathComponent("cwd") + let executable = try script(in: directory, body: """ + printf '%s\\n' "$*" > '\(record.path)' + pwd > '\(cwd.path)' + IFS= read -r line + printf '%s\\n' "$line" >> '\(record.path)' + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"_meta":{"agentVersion":"9.8.7"}}}' + IFS= read -r line + printf '%s\\n' "$line" >> '\(record.path)' + case "$line" in + *'"method":"_x.ai/billing"'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":\(current)}' ;; + *) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"error":{"code":-32601}}' ;; + esac + """) + let result = try await GrokBillingClient().fetch(executableURL: executable) + XCTAssertEqual(result.sourceVersion, "9.8.7") + XCTAssertEqual(result.remainingPercent, 74.5) + let lines = try String(contentsOf: record).split(separator: "\n") + XCTAssertEqual(lines.count, 3) + XCTAssertEqual(lines[0], "agent --no-leader stdio") + let initRequest = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as? [String: Any]) + XCTAssertEqual(initRequest["method"] as? String, "initialize") + let params = try XCTUnwrap(initRequest["params"] as? [String: Any]) + let capabilities = try XCTUnwrap(params["clientCapabilities"] as? [String: Any]) + XCTAssertEqual(capabilities["terminal"] as? Bool, false) + let fs = try XCTUnwrap(capabilities["fs"] as? [String: Bool]) + XCTAssertEqual(fs, ["readTextFile": false, "writeTextFile": false]) + let workingDirectory = try String(contentsOf: cwd).trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertTrue(workingDirectory.contains("CodexLimits-Grok-")) + XCTAssertFalse(FileManager.default.fileExists(atPath: workingDirectory)) + XCTAssertFalse(GrokBillingClient.isExecutable(directory)) + } + + func testTransportRejectsProtocolErrorsAndBoundsTotalOutput() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + for (code, expected) in [(-32601, GrokBillingError.unsupported), (-32000, .authenticationRequired), (-32603, .failed)] { + let executable = try script(in: directory, body: """ + IFS= read -r line + printf '%s\\n' '{"jsonrpc":"2.0","id":1,"error":{"code":\(code),"message":"PRIVATE"}}' + """) + do { + _ = try await GrokBillingClient().fetch(executableURL: executable) + XCTFail("Expected an error") + } catch { + XCTAssertEqual(error as? GrokBillingError, expected) + XCTAssertFalse(error.localizedDescription.contains("PRIVATE")) + } + } + let oversized = try script(in: directory, body: """ + IFS= read -r line + /usr/bin/head -c 1048577 /dev/zero + """) + do { + _ = try await GrokBillingClient().fetch(executableURL: oversized) + XCTFail("Expected output rejection") + } catch { + XCTAssertEqual(error as? GrokBillingError, .invalidResponse) + } + } + + func testDeadlineAndCancellationTerminateTheOwnedProcessGroup() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + for cancellation in [false, true] { + let record = directory.appendingPathComponent(UUID().uuidString) + let executable = try script(in: directory, body: """ + /bin/sleep 60 & + child=$! + printf '%s %s\\n' "$$" "$child" > '\(record.path)' + trap 'kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; exit 0' TERM + wait "$child" + """) + let started = Date() + let task = Task { + try await GrokBillingClient(timeout: cancellation ? 10 : 0.2) + .fetch(executableURL: executable) + } + if cancellation { + for _ in 0..<100 where !FileManager.default.fileExists(atPath: record.path) { + try await Task.sleep(for: .milliseconds(10)) + } + task.cancel() + } + do { + _ = try await task.value + XCTFail("Expected interruption") + } catch { + if cancellation { XCTAssertTrue(error is CancellationError) } + else { XCTAssertEqual(error as? GrokBillingError, .timedOut) } + } + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + let pids = try String(contentsOf: record).split(whereSeparator: \.isWhitespace).compactMap { Int32($0) } + XCTAssertEqual(pids.count, 2) + for pid in pids { + XCTAssertEqual(kill(pid, 0), -1) + XCTAssertEqual(errno, ESRCH) + } + } + } + + private func decode(_ json: String) throws -> GrokAllowanceSnapshot { + try GrokAllowanceSnapshot.decode(Data(json.utf8), observedAt: observedAt, sourceVersion: "1.2.3") + } + + private func script(in directory: URL, body: String) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let executable = directory.appendingPathComponent("fake-grok") + try Data(("#!/bin/sh\n" + body + "\n").utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + return executable + } +} diff --git a/Tests/CodexLimitsTests/GrokIntegrationTests.swift b/Tests/CodexLimitsTests/GrokIntegrationTests.swift new file mode 100644 index 0000000..ae05006 --- /dev/null +++ b/Tests/CodexLimitsTests/GrokIntegrationTests.swift @@ -0,0 +1,324 @@ +import XCTest +import ClaudeIntegrationCore +@testable import CodexLimits + +@MainActor +final class GrokIntegrationTests: XCTestCase { + func testOverviewReadsCurrentPeriodWithoutRetainingDetailAndFallsBackToLatest() async throws { + let clock = GrokTestClock() + let current = fixture(at: clock.now()) + let source = GrokFetchProbe(snapshot: current) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let historyDirectory = cache.deletingLastPathComponent().appendingPathComponent("History") + let previous = AllowanceObservation( + metric: current.historyObservation.metric, observedAt: current.observedAt.addingTimeInterval(-600), + remainingPercent: 80, resetsAt: current.resetsAt, + startsAt: current.historyObservation.startsAt, source: current.measurementSource + ) + let old = fixture(at: clock.now().addingTimeInterval(-20 * 86_400)).historyObservation + try AllowanceHistory.append([old, previous], in: historyDirectory) + try JSONEncoder().encode(current).write(to: cache) + let files = try FileManager.default.contentsOfDirectory(at: historyDirectory, includingPropertiesForKeys: nil) + .sorted { $0.lastPathComponent < $1.lastPathComponent } + let oldest = try XCTUnwrap(files.first) + try Data("invalid old archive\n".utf8).write(to: oldest) + let store = makeStore(clock: clock, source: source, cache: cache) + await store.refresh(force: false) + XCTAssertNil(store.overview) + XCTAssertNil(store.historyIssue) + + await store.setVisible(true, includeHistory: false, safetyBuffer: 5) + + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 2) + XCTAssertEqual(store.overview?.latest?.remaining, 75) + XCTAssertEqual(store.overview?.target.last?.remaining, 5) + XCTAssertNil(store.historyIssue) + var calls = await source.calls + XCTAssertEqual(calls, 0, "Overview demand must reuse a fresh cache") + await store.setVisible(true) + XCTAssertNil(store.overview) + XCTAssertNotNil(store.historyIssue) + await store.setVisible(false) + XCTAssertNil(store.overview) + let newest = try XCTUnwrap(files.last) + try Data("invalid current archive\n".utf8).write(to: newest) + await store.setVisible(true, includeHistory: false) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + XCTAssertEqual(store.overview?.latest?.remaining, 75) + XCTAssertNotNil(store.historyIssue) + calls = await source.calls + XCTAssertEqual(calls, 0) + await store.setEnabled(false) + XCTAssertNil(store.overview) + await store.deleteData() + XCTAssertNil(store.overview) + } + + func testSwitchingToOverviewDuringSharedFetchPublishesOnlyCompactHistory() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let store = makeStore(clock: clock, source: source) + let detail = Task { await store.setVisible(true) } + await waitForCall(source) + let overview = Task { await store.setVisible(true, includeHistory: false) } + await Task.yield() + await source.release() + await detail.value + await overview.value + XCTAssertTrue(store.history.isEmpty) + XCTAssertEqual(store.overview?.observedSegments.flatMap { $0 }.count, 1) + let calls = await source.calls + XCTAssertEqual(calls, 1) + await store.deleteData() + XCTAssertNil(store.overview) + } + + func testRecordedHistoryMigratesLatestCacheAndSurvivesRelaunchUntilDeletion() async throws { + let clock = GrokTestClock() + let first = fixture(at: clock.now()) + let source = GrokFetchProbe(snapshot: first) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(first).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache) + await store.setVisible(true) + XCTAssertEqual(store.history, [first.historyObservation], "Migrate one real cached observation only") + clock.advance(600) + let second = fixture(at: clock.now()) + await source.setSnapshot(second) + await store.refresh() + XCTAssertEqual(store.history, [first.historyObservation, second.historyObservation]) + await store.setEnabled(false) + let restored = makeStore(clock: clock, source: source, cache: cache) + await restored.setVisible(true) + XCTAssertEqual(restored.history, [first.historyObservation, second.historyObservation]) + await restored.setVisible(false) + XCTAssertTrue(restored.history.isEmpty, "Hidden detail releases resident history") + await restored.setVisible(true) + XCTAssertEqual(restored.history.count, 2) + await restored.deleteData() + XCTAssertTrue(restored.history.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.deletingLastPathComponent().appendingPathComponent("History").path)) + } + + func testOnlyEnabledDemandFetchesAndTheCacheSurvivesRelaunch() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let store = makeStore(enabled: false, clock: clock, source: source, cache: cache) + await store.settingsPresented() + await store.refresh() + await store.setVisible(true) + var calls = await source.calls + XCTAssertEqual(calls, 0) + + await store.setEnabled(true) + XCTAssertEqual(store.menuBarText, "75%") + calls = await source.calls + XCTAssertEqual(calls, 1) + let attributes = try FileManager.default.attributesOfItem(atPath: cache.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + await store.setVisible(false) + await store.setEnabled(false) + + let restored = makeStore(enabled: true, clock: clock, source: source, cache: cache) + await restored.settingsPresented() + XCTAssertNil(restored.snapshot) + await restored.setVisible(true) + XCTAssertEqual(restored.snapshot, store.snapshot) + calls = await source.calls + XCTAssertEqual(calls, 1, "Opening a fresh cache must not start a provider process") + await restored.setVisible(false) + clock.advance(700) + await restored.refresh(force: false, priority: .visible) + calls = await source.calls + XCTAssertEqual(calls, 1, "Hidden and unselected Grok has no source demand") + await restored.deleteData() + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + XCTAssertNil(restored.snapshot) + XCTAssertFalse(restored.hasStoredData) + } + + func testThrottledFailuresKeepUsageUntilResetAndBackOff() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let store = makeStore(clock: clock, source: source) + await store.refresh() + clock.advance(31) + await source.fail(with: .failed) + await store.refresh() + XCTAssertEqual(store.menuBarText, "75%") + XCTAssertTrue(store.isStale) + await store.refresh() + var calls = await source.calls + XCTAssertEqual(calls, 2, "Explicit refresh must honor the 30-second floor") + clock.advance(600) + await store.refresh(force: false) + calls = await source.calls + XCTAssertEqual(calls, 3) + clock.advance(600) + await store.refresh(force: false) + calls = await source.calls + XCTAssertEqual(calls, 3, "A second failure backs off for 20 minutes") + clock.advance(3_000) + store.updateDisplayTime() + XCTAssertEqual(store.menuBarText, "—") + XCTAssertNotNil(store.snapshot) + XCTAssertNil(store.currentSnapshot) + XCTAssertFalse(store.isStale, "Reset expiration takes precedence over stale usage") + } + + func testWallClockChangesCannotBypassTheLaunchFloor() async { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let store = makeStore(clock: clock, source: source) + await store.refresh() + clock.advance(3_600, uptime: 0) + await store.refresh() + let calls = await source.calls + XCTAssertEqual(calls, 1) + XCTAssertEqual(store.menuBarText, "—") + } + + func testDeletionWaitsForLateWorkAndSupersedesExecutableSelection() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + let store = makeStore(clock: clock, source: source, cache: cache) + let selection = Task { await store.selectExecutable(URL(fileURLWithPath: "/usr/bin/true")) } + await waitForCall(source) + let deletion = Task { await store.deleteData() } + while store.isRefreshing { await Task.yield() } + await source.release() + let selected = await selection.value + await deletion.value + XCTAssertFalse(selected, "Settings must not restore an executable after deletion") + XCTAssertNil(store.snapshot) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + await store.refresh() + let calls = await source.calls + XCTAssertEqual(calls, 1) + } + + func testExplicitRefreshPromotesQueuedFreshCacheWork() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let coordinator = IntegrationWorkCoordinator() + let gate = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let blocker = Task { + await coordinator.run(priority: .explicit) { _ = try? await gate.fetch() } + } + await waitForCall(gate) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(fixture(at: clock.now())).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache, coordinator: coordinator) + let visible = Task { await store.setVisible(true) } + await Task.yield() + let explicit = Task { await store.refresh() } + await Task.yield() + await gate.release() + await blocker.value + await visible.value + await explicit.value + let calls = await source.calls + XCTAssertEqual(calls, 1) + await store.setVisible(false) + } + + func testDismissedSettingsDoesNotPublishQueuedReadiness() async throws { + let clock = GrokTestClock() + let source = GrokFetchProbe(snapshot: fixture(at: clock.now())) + let coordinator = IntegrationWorkCoordinator() + let gate = GrokFetchProbe(snapshot: fixture(at: clock.now()), held: true) + let blocker = Task { + await coordinator.run(priority: .explicit) { _ = try? await gate.fetch() } + } + await waitForCall(gate) + let cache = temporaryDirectory().appendingPathComponent("snapshot.json") + try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(fixture(at: clock.now())).write(to: cache) + let store = makeStore(clock: clock, source: source, cache: cache, coordinator: coordinator) + let settings = Task { await store.settingsPresented() } + await Task.yield() + store.settingsDismissed() + await gate.release() + await blocker.value + await settings.value + XCTAssertFalse(store.hasStoredData) + let calls = await source.calls + XCTAssertEqual(calls, 0) + } + + private func makeStore( + enabled: Bool = true, clock: GrokTestClock, source: GrokFetchProbe, + cache: URL? = nil, coordinator: IntegrationWorkCoordinator = IntegrationWorkCoordinator() + ) -> GrokIntegrationStore { + GrokIntegrationStore( + isEnabled: enabled, + selectedExecutableURL: URL(fileURLWithPath: "/usr/bin/true"), + cacheURL: cache ?? temporaryDirectory().appendingPathComponent("snapshot.json"), + integrationWorkCoordinator: coordinator, + fetchUsage: { _ in try await source.fetch() }, + now: { clock.now() }, uptime: { clock.uptime() } + ) + } + + private func fixture(at now: Date) -> GrokAllowanceSnapshot { + GrokAllowanceSnapshot( + reportedUsedPercent: 25, period: .weekly, + resetsAt: now.addingTimeInterval(3_600), observedAt: now, + sourceVersion: "1.2.3", subscriptionTier: nil, + prepaidBalanceUSD: nil, onDemandUsedUSD: nil, onDemandCapUSD: nil, + isUnifiedBilling: true, measurementSource: "creditUsagePercent" + ) + } + + private func waitForCall(_ source: GrokFetchProbe) async { + for _ in 0..<1_000 { + if await source.calls > 0 { return } + try? await Task.sleep(for: .milliseconds(1)) + } + XCTFail("Source work did not start") + } +} + +private final class GrokTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date = Date(timeIntervalSince1970: 1_800_000_000) + private var elapsed: TimeInterval = 0 + func now() -> Date { lock.withLock { date } } + func uptime() -> TimeInterval { lock.withLock { elapsed } } + func advance(_ seconds: TimeInterval, uptime: TimeInterval? = nil) { + lock.withLock { + date.addTimeInterval(seconds) + elapsed += uptime ?? seconds + } + } +} + +private actor GrokFetchProbe { + private var snapshot: GrokAllowanceSnapshot + private var error: GrokBillingError? + private var held: Bool + private var continuation: CheckedContinuation? + private(set) var calls = 0 + init(snapshot: GrokAllowanceSnapshot, held: Bool = false) { + self.snapshot = snapshot + self.held = held + } + func fail(with error: GrokBillingError) { self.error = error } + func setSnapshot(_ snapshot: GrokAllowanceSnapshot) { self.snapshot = snapshot } + func fetch() async throws -> GrokAllowanceSnapshot { + calls += 1 + if held { await withCheckedContinuation { continuation = $0 } } + if let error { throw error } + return snapshot + } + func release() { + held = false + continuation?.resume() + continuation = nil + } +} diff --git a/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift b/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift new file mode 100644 index 0000000..36630ea --- /dev/null +++ b/Tests/CodexLimitsTests/IntegrationAllowanceChartTests.swift @@ -0,0 +1,81 @@ +import ClaudeIntegrationCore +import XCTest +@testable import CodexLimits + +final class IntegrationAllowanceChartTests: XCTestCase { + private let date = Date(timeIntervalSince1970: 1_800_000_000) + + func testOnePointHasNoInventedHistoryAndFreshCompatiblePointsForecast() throws { + let first = point(0, 80) + let second = point(600, 75) + let single = try chart([first], now: 0) + XCTAssertEqual(single.chart.observed.count, 1) + XCTAssertTrue(single.chart.currentProjection.isEmpty) + let recorded = try chart([first, second], now: 600) + XCTAssertEqual(recorded.chart.observed.map(\.remaining), [80, 75]) + XCTAssertEqual(recorded.chart.currentProjection.first?.date, second.observedAt) + XCTAssertEqual(recorded.chart.currentProjection.last?.date, second.resetsAt) + XCTAssertEqual(recorded.chart.currentProjection.last?.remaining, 50) + XCTAssertEqual(recorded.chart.target.first?.date, first.startsAt) + XCTAssertNil(recorded.chart.reference) + XCTAssertTrue(try chart([first, point(30, 79)], now: 30).chart.currentProjection.isEmpty) + } + + func testGapsCorrectionsResetsStalenessAndConflictsBreakForecasts() throws { + let first = point(0, 80) + for next in [point(1_801, 75), point(600, 90), point(600, 75, reset: 7_200)] { + let value = try chart([first, next], now: next.observedAt.timeIntervalSince(date)) + XCTAssertTrue(value.chart.currentProjection.isEmpty) + XCTAssertEqual(value.chart.allObservedSegments.count, 2) + } + XCTAssertTrue(try chart([first, point(600, 75)], now: 600, stale: true).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(600, 75)], now: 2_400).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(600, 75)], now: 3_601).chart.currentProjection.isEmpty) + XCTAssertTrue(try chart([first, point(0, 90), point(600, 75)], now: 600).chart.currentProjection.isEmpty) + } + + func testMetricsStaySeparateAndUnknownMonthlyStartHasNoTarget() throws { + let monthly = AllowanceObservation(metric: "grok-monthly", observedAt: date, remainingPercent: 25, resetsAt: date.addingTimeInterval(30 * 86_400)) + let other = AllowanceObservation(metric: "claude-five-hour", observedAt: date, remainingPercent: 99, resetsAt: date.addingTimeInterval(3_600)) + let data = try XCTUnwrap(IntegrationAllowanceChart(metric: monthly.metric, observations: [monthly, other], current: nil, now: date, isStale: false, safetyBuffer: 3)) + XCTAssertEqual(data.chart.observed.map(\.remaining), [25]) + XCTAssertTrue(data.chart.target.isEmpty) + XCTAssertTrue(data.chart.currentProjection.isEmpty) + XCTAssertNil(IntegrationAllowanceChart(metric: "missing", observations: [monthly], current: nil, now: date, isStale: false, safetyBuffer: 3)) + } + + func testMissingCurrentWindowOrChangedMeasurementCannotExtendOldForecast() throws { + let first = point(0, 80) + let second = point(600, 75) + let noCurrent = try XCTUnwrap(IntegrationAllowanceChart(metric: first.metric, observations: [first, second], current: nil, now: second.observedAt, isStale: false, safetyBuffer: 3)) + XCTAssertEqual(noCurrent.chart.observed.count, 2) + XCTAssertTrue(noCurrent.chart.currentProjection.isEmpty) + for changed in [ + AllowanceObservation(metric: first.metric, observedAt: second.observedAt, remainingPercent: 75, resetsAt: first.resetsAt, startsAt: first.startsAt, source: "new-source"), + AllowanceObservation(metric: first.metric, observedAt: second.observedAt, remainingPercent: 75, resetsAt: first.resetsAt, startsAt: first.startsAt?.addingTimeInterval(60)) + ] { + let result = try chart([first, changed], now: 600) + XCTAssertTrue(result.chart.currentProjection.isEmpty) + XCTAssertEqual(result.chart.allObservedSegments.count, 2) + } + } + + @MainActor + func testChartRangeIsIsolatedFromCodexAndOtherProviderMetrics() { + let name = "IntegrationAllowanceChartTests-\(UUID())" + let defaults = UserDefaults(suiteName: name)! + defer { defaults.removePersistentDomain(forName: name) } + AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "grok-weekly.").selectTimeRange(.fourWeeks) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "grok-weekly.").state.timeRange, .fourWeeks) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults, keyPrefix: "claude-seven-day.").state.timeRange, .currentWindow) + XCTAssertEqual(AnalyticsWorkspaceStore(defaults: defaults).state.timeRange, .currentWindow) + } + + private func point(_ seconds: TimeInterval, _ remaining: Double, reset: TimeInterval = 3_600) -> AllowanceObservation { + AllowanceObservation(metric: "grok-weekly", observedAt: date.addingTimeInterval(seconds), remainingPercent: remaining, resetsAt: date.addingTimeInterval(reset), startsAt: date.addingTimeInterval(reset - 7 * 86_400)) + } + + private func chart(_ observations: [AllowanceObservation], now: TimeInterval, stale: Bool = false) throws -> IntegrationAllowanceChart { + try XCTUnwrap(IntegrationAllowanceChart(metric: "grok-weekly", observations: observations, current: observations.last, now: date.addingTimeInterval(now), isStale: stale, safetyBuffer: 3)) + } +} diff --git a/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift b/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift new file mode 100644 index 0000000..c766b1f --- /dev/null +++ b/Tests/CodexLimitsTests/IntegrationPreferencesTests.swift @@ -0,0 +1,157 @@ +import XCTest +@testable import CodexLimits + +@MainActor +final class IntegrationPreferencesTests: XCTestCase { + func testExistingInstallDefaultsToCodexAndItsWeeklyMenuMetric() { + let preferences = IntegrationPreferences(defaults: defaults()) + + XCTAssertEqual(preferences.enabledIntegrations, [.codex]) + XCTAssertEqual( + preferences.menuBarMetric, + .codexWeeklyUsageRemaining + ) + XCTAssertEqual( + preferences.availableMenuBarMetrics, + [.none, .codexWeeklyUsageRemaining] + ) + } + + func testDisablingTheSelectedIntegrationSelectsNoneAndPersists() { + let defaults = defaults() + let preferences = IntegrationPreferences(defaults: defaults) + + preferences.setEnabled(false, for: .codex) + let restored = IntegrationPreferences(defaults: defaults) + + XCTAssertTrue(restored.enabledIntegrations.isEmpty) + XCTAssertEqual(restored.menuBarMetric, .none) + XCTAssertEqual(restored.availableMenuBarMetrics, [.none]) + } + + func testMenuMetricRequiresItsIntegrationToBeEnabled() { + let preferences = IntegrationPreferences(defaults: defaults()) + + preferences.selectMenuBarMetric(.claudeSevenDayUsageRemaining) + XCTAssertEqual( + preferences.menuBarMetric, + .codexWeeklyUsageRemaining + ) + + preferences.setEnabled(true, for: .claudeCode) + preferences.selectMenuBarMetric(.claudeSevenDayUsageRemaining) + XCTAssertEqual( + preferences.menuBarMetric, + .claudeSevenDayUsageRemaining + ) + } + + func testDeferredIntegrationCannotReturnFromStoredPreferences() { + let defaults = defaults() + defaults.set( + Data( + """ + {"version":1,"enabledIntegrationIDs":["openCode"],"menuBarMetricID":"openCodeSevenDayLocalTokens"} + """.utf8 + ), + forKey: IntegrationPreferences.persistenceKey + ) + + let preferences = IntegrationPreferences(defaults: defaults) + + XCTAssertTrue(preferences.enabledIntegrations.isEmpty) + XCTAssertEqual(preferences.menuBarMetric, .none) + XCTAssertEqual(preferences.availableMenuBarMetrics, [.none]) + } + + func testExecutableSelectionsPersistAndCanBeDeleted() { + let defaults = defaults() + let preferences = IntegrationPreferences(defaults: defaults) + let codex = URL(fileURLWithPath: "/custom/bin/codex") + let claude = URL(fileURLWithPath: "/custom/bin/claude") + let grok = URL(fileURLWithPath: "/custom/bin/grok") + + preferences.selectCodexExecutable(codex) + preferences.selectClaudeExecutable(claude) + preferences.selectGrokExecutable(grok) + preferences.setEnabled(true, for: .grok) + preferences.selectMenuBarMetric(.grokCurrentPeriodUsageRemaining) + var restored = IntegrationPreferences(defaults: defaults) + XCTAssertEqual(restored.codexExecutableURL, codex) + XCTAssertEqual(restored.claudeExecutableURL, claude) + XCTAssertEqual(restored.grokExecutableURL, grok) + XCTAssertEqual(restored.menuBarMetric, .grokCurrentPeriodUsageRemaining) + + preferences.selectCodexExecutable(nil) + preferences.selectClaudeExecutable(nil) + preferences.selectGrokExecutable(nil) + preferences.setEnabled(false, for: .grok) + restored = IntegrationPreferences(defaults: defaults) + XCTAssertNil(restored.codexExecutableURL) + XCTAssertNil(restored.claudeExecutableURL) + XCTAssertNil(restored.grokExecutableURL) + XCTAssertEqual(restored.menuBarMetric, .none) + } + + func testWorkCoordinatorSerializesAndPrioritizesExplicitWork() async { + let coordinator = IntegrationWorkCoordinator() + let probe = IntegrationWorkProbe() + let first = Task { + await coordinator.run(priority: .automatic) { + await probe.begin("automatic") + try? await Task.sleep(for: .milliseconds(80)) + await probe.end() + } + } + while await probe.startedCount == 0 { + await Task.yield() + } + let settings = Task { + await coordinator.run(priority: .settings) { + await probe.begin("settings") + await probe.end() + } + } + let explicit = Task { + await coordinator.run(priority: .explicit) { + await probe.begin("explicit") + await probe.end() + } + } + + await first.value + await settings.value + await explicit.value + let result = await probe.result + XCTAssertEqual(result.order, ["automatic", "explicit", "settings"]) + XCTAssertEqual(result.maximumActive, 1) + } + + private func defaults() -> UserDefaults { + let suite = "IntegrationPreferencesTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } +} + +private actor IntegrationWorkProbe { + private var active = 0 + private var maximumActive = 0 + private var order: [String] = [] + + var startedCount: Int { order.count } + var result: (order: [String], maximumActive: Int) { + (order, maximumActive) + } + + func begin(_ name: String) { + active += 1 + maximumActive = max(maximumActive, active) + order.append(name) + } + + func end() { + active -= 1 + } +} diff --git a/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift b/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift index 2803429..4820a29 100644 --- a/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift +++ b/Tests/CodexLimitsTests/LocalActivityPerformanceTests.swift @@ -260,116 +260,6 @@ final class LocalActivityPerformanceTests: XCTestCase { XCTAssertLessThan(residentDelta, 256 * 1_024 * 1_024) } - func testRepresentativeFixtureMetrics() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - let fileURL = directory.appendingPathComponent("representative.jsonl") - - let recordCount = 20_000 - var fixture = - #"{"timestamp":"2026-07-27T10:00:00.000Z","ordinal":0,"type":"session_meta","payload":{"id":"task-benchmark","cli_version":"0.145.0","history_mode":"paginated"}}"# - + "\n" - fixture.reserveCapacity(recordCount * 180) - for ordinal in 1...recordCount { - fixture += - #"{"timestamp":"2026-07-27T10:00:01.000Z","ordinal":\#(ordinal),"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":\#(ordinal * 100)}}}}"# - + "\n" - } - let fixtureByteCount = fixture.utf8.count - try Data(fixture.utf8).write(to: fileURL) - fixture.removeAll(keepingCapacity: false) - - let source = IncrementalRolloutTailSource() - let residentBeforeInitialRead = currentResidentBytes() - let initialStart = ProcessInfo.processInfo.systemUptime - let initial = try source.read( - fileURL: fileURL, - cursor: nil, - observedAt: Date(timeIntervalSince1970: 100) - ) - let initialMilliseconds = - (ProcessInfo.processInfo.systemUptime - initialStart) * 1_000 - let residentAfterInitialRead = currentResidentBytes() - let initialResidentDelta = residentAfterInitialRead >= residentBeforeInitialRead - ? residentAfterInitialRead - residentBeforeInitialRead - : 0 - - let idleRefreshCount = 1_000 - let residentBeforeIdleRefreshes = currentResidentBytes() - let idleCPUStart = clock() - let idleWallStart = ProcessInfo.processInfo.systemUptime - var idleBytesRead: UInt64 = 0 - var idleRecords = 0 - for _ in 0..= residentBeforeIdleRefreshes - ? residentAfterIdleRefreshes - residentBeforeIdleRefreshes - : 0 - - let appended = - #"{"timestamp":"2026-07-27T10:00:02.000Z","ordinal":20001,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":2000100}}}}"# - + "\n" - let handle = try FileHandle(forWritingTo: fileURL) - try handle.seekToEnd() - try handle.write(contentsOf: Data(appended.utf8)) - try handle.close() - let appendedStart = ProcessInfo.processInfo.systemUptime - let incremental = try source.read( - fileURL: fileURL, - cursor: initial.cursor, - observedAt: Date(timeIntervalSince1970: 300) - ) - let appendedMilliseconds = - (ProcessInfo.processInfo.systemUptime - appendedStart) * 1_000 - var usage = rusage() - getrusage(RUSAGE_SELF, &usage) - - print( - [ - "LOCAL_ACTIVITY_METRICS", - "fixture_bytes=\(fixtureByteCount)", - "fixture_records=\(initial.records.count)", - String(format: "initial_ms=%.3f", initialMilliseconds), - "initial_resident_delta_bytes=\(initialResidentDelta)", - "idle_refreshes=\(idleRefreshCount)", - String(format: "idle_wall_ms=%.3f", idleWallMilliseconds), - String(format: "idle_cpu_ms=%.3f", idleCPUMilliseconds), - "idle_resident_delta_bytes=\(idleResidentDelta)", - "idle_bytes=\(idleBytesRead)", - "idle_records=\(idleRecords)", - "incremental_bytes=\(incremental.bytesRead)", - "incremental_records=\(incremental.records.count)", - String(format: "incremental_ms=%.3f", appendedMilliseconds), - "max_rss_bytes=\(usage.ru_maxrss)" - ].joined(separator: " ") - ) - - XCTAssertEqual(initial.records.count, recordCount + 1) - XCTAssertEqual(idleBytesRead, 0) - XCTAssertEqual(idleRecords, 0) - XCTAssertEqual( - incremental.bytesRead, - UInt64(appended.utf8.count) - + (initial.cursor.checkpoint?.byteLength ?? 0) - ) - XCTAssertEqual(incremental.records.count, 1) - } - private func currentResidentBytes() -> UInt64 { var info = mach_task_basic_info() var count = mach_msg_type_number_t( diff --git a/Tests/CodexLimitsTests/LocalTokenActivityTests.swift b/Tests/CodexLimitsTests/LocalTokenActivityTests.swift index f9b9009..41882c5 100644 --- a/Tests/CodexLimitsTests/LocalTokenActivityTests.swift +++ b/Tests/CodexLimitsTests/LocalTokenActivityTests.swift @@ -28,8 +28,6 @@ final class LocalTokenActivityTests: XCTestCase { XCTAssertEqual(activity.sourceVersion, "0.145.0") XCTAssertEqual(activity.observedAt, Date(timeIntervalSince1970: 2_000)) XCTAssertEqual(activity.points.map(\.tokens), [100, 350]) - XCTAssertNil(activity.accountComparison.numericPercent) - XCTAssertFalse(activity.accountComparison.comparable) } func testReadsFractionalSecondTimestampsFromRealRollouts() { @@ -294,8 +292,6 @@ final class LocalTokenActivityTests: XCTestCase { reader.localTokenActivity.interval, reader.accountTokenActivity.interval ) - XCTAssertFalse(reader.localTokenActivity.accountComparison.comparable) - XCTAssertNil(reader.localTokenActivity.accountComparison.numericPercent) } func testOffDeviceActivityDoesNotTurnTheAccountLocalGapIntoCoverage() { @@ -334,8 +330,6 @@ final class LocalTokenActivityTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.tokens, 1_000) XCTAssertEqual(reader.localTokenActivity.tokens, 100) - XCTAssertNil(reader.localTokenActivity.accountComparison.numericPercent) - XCTAssertFalse(reader.localTokenActivity.accountComparison.comparable) } func testZeroAccountAndLocalActivityRemainFactual() { diff --git a/Tests/CodexLimitsTests/ResetReminderTests.swift b/Tests/CodexLimitsTests/ResetReminderTests.swift index 4e8c5ac..9bcd656 100644 --- a/Tests/CodexLimitsTests/ResetReminderTests.swift +++ b/Tests/CodexLimitsTests/ResetReminderTests.swift @@ -27,9 +27,7 @@ final class ResetReminderTests: XCTestCase { fixture.scheduler.events.last, .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(48 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -56,7 +54,6 @@ final class ResetReminderTests: XCTestCase { ) XCTAssertTrue(fixture.coordinator.state.isEnabled) - XCTAssertEqual(fixture.coordinator.state.authorization, .denied) XCTAssertEqual(fixture.coordinator.state.delivery, .permissionDenied) XCTAssertEqual( fixture.scheduler.events, @@ -80,9 +77,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(42 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 6 hours." ) @@ -126,9 +121,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(48 * 60 * 60), - expiresAt: changed.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -213,9 +206,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(24 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -259,10 +250,7 @@ final class ResetReminderTests: XCTestCase { XCTAssertTrue(scheduler.events.isEmpty) XCTAssertEqual( restarted.state.delivery, - .reminderTimePassed( - Date(timeIntervalSince1970: 1_800_000_000) - .addingTimeInterval(24 * 60 * 60) - ) + .reminderTimePassed ) } @@ -297,10 +285,7 @@ final class ResetReminderTests: XCTestCase { XCTAssertEqual(coordinator.state.leadTime, .hours6) XCTAssertEqual( coordinator.state.delivery, - .reminderTimePassed( - Date(timeIntervalSince1970: 1_800_000_000) - .addingTimeInterval(24 * 60 * 60) - ) + .reminderTimePassed ) } @@ -331,9 +316,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(24 * 60 * 60), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 24 hours." ) @@ -408,9 +391,7 @@ final class ResetReminderTests: XCTestCase { [ .scheduled( ResetReminderRequest( - resetID: "reset-1", firesAt: fixture.now.addingTimeInterval(1), - expiresAt: target.expiresAt, title: "Banked reset expires soon", body: "A banked reset expires in 1 hour." ) diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift index 444dc16..c770905 100644 --- a/Tests/CodexLimitsTests/UsageHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -1407,13 +1407,21 @@ final class UsageHistoryTests: XCTestCase { _ = await receiver.load() _ = await receiver.connect(to: shared) - let corruptWriter = shared - .appendingPathComponent("installations", isDirectory: true) - .appendingPathComponent("a-corrupt", isDirectory: true) - try FileManager.default.createDirectory(at: corruptWriter, withIntermediateDirectories: true) - try Data("broken".utf8).write( - to: corruptWriter.appendingPathComponent("0000-broken.json") + let corruptWriter = UsageHistory( + localDirectory: root.appendingPathComponent("corrupt", isDirectory: true), + installationID: "a-corrupt" + ) + _ = await corruptWriter.load() + _ = await corruptWriter.connect(to: shared) + _ = await corruptWriter.record(UsageSample( + observedAt: now.addingTimeInterval(-60), + remainingPercent: 81, + resetsAt: now.addingTimeInterval(86_400) + )) + let corruptFile = try XCTUnwrap( + jsonFiles(for: "a-corrupt", in: shared).first ) + try Data("broken".utf8).write(to: corruptFile) let sample = UsageSample( observedAt: now, @@ -1463,6 +1471,349 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(jsonFiles(for: "writer-a", in: root).count, 1) } + func testTenYearRetentionPublishesABoundedWorkingSet() async throws { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_600_000_000) + let dayCount = 10 * 365 + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + _ = await history.load() + for day in 0 ..< dayCount { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + + let reloaded = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + let loadStartedAt = ProcessInfo.processInfo.systemUptime + let state = await reloaded.load() + let loadMilliseconds = ( + ProcessInfo.processInfo.systemUptime - loadStartedAt + ) * 1_000 + + XCTAssertEqual(jsonFiles(for: "writer-a", in: root).count, dayCount) + XCTAssertLessThanOrEqual(state.samples.count, 90) + XCTAssertEqual( + state.samples.last?.observedAt, + start.addingTimeInterval(Double(dayCount - 1) * 86_400) + ) + let newestFile = try XCTUnwrap( + jsonFiles(for: "writer-a", in: root).max { + $0.lastPathComponent < $1.lastPathComponent + } + ) + try Data("broken".utf8).write(to: newestFile) + let refreshStartedAt = ProcessInfo.processInfo.systemUptime + let automatic = await reloaded.synchronizeIfDue() + let refreshMilliseconds = ( + ProcessInfo.processInfo.systemUptime - refreshStartedAt + ) * 1_000 + XCTAssertNil(automatic.errorMessage) + XCTAssertEqual(automatic.samples, state.samples) + let explicit = await reloaded.synchronize() + XCTAssertEqual( + explicit.errorMessage, + "Some usage history couldn’t be read." + ) + print( + String( + format: "BOUNDED_HISTORY days=%d samples=%d cold_load_ms=%.3f automatic_refresh_ms=%.3f", + dayCount, + state.samples.count, + loadMilliseconds, + refreshMilliseconds + ) + ) + } + + func testDenseHistoryCannotExceedTheHardWorkingSetCap() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let samples = (0 ..< 14_400).map { minute in + let observedAt = start.addingTimeInterval(Double(minute) * 60) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - minute % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + ) + } + let state = await UsageHistory( + localDirectory: temporaryDirectory(), + installationID: "writer-a" + ).load(legacySamples: samples) + + XCTAssertEqual(state.samples.count, 6_000) + XCTAssertEqual(state.samples.last?.observedAt, samples.last?.observedAt) + } + + func testColdWorkingSetReadCapsInstallationFanOut() async { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for index in 0 ..< 33 { + let history = UsageHistory( + localDirectory: root, + installationID: String(format: "writer-%02d", index) + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: start.addingTimeInterval(Double(index)), + remainingPercent: Double(index), + resetsAt: start.addingTimeInterval(86_400) + )) + } + + let state = await UsageHistory( + localDirectory: root, + installationID: "writer-00" + ).load() + + XCTAssertEqual( + state.errorMessage, + "Some usage history couldn’t be read." + ) + XCTAssertLessThan(state.samples.count, 33) + } + + func testOlderRangeLoadsASeparateBoundedView() async throws { + let root = temporaryDirectory() + let start = Date(timeIntervalSince1970: 1_600_000_000) + let samples = (0 ..< 200).map { day in + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + ) + } + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + let defaultState = await history.load(legacySamples: samples) + let requested = DateInterval( + start: samples[20].observedAt, + end: samples[103].observedAt.addingTimeInterval(1) + ) + let loadedView = await history.rangeView(for: requested) + let view = try XCTUnwrap(loadedView) + + XCTAssertGreaterThan(defaultState.samples.first!.observedAt, requested.end) + XCTAssertEqual( + view.samples, + samples.filter { requested.contains($0.observedAt) } + ) + XCTAssertEqual(view.resolution, .exact) + XCTAssertFalse(view.hadReadError) + XCTAssertEqual(view.coveredInterval, requested) + XCTAssertLessThanOrEqual( + view.retainedBounds!.start, + samples.first!.observedAt + ) + XCTAssertGreaterThan( + view.retainedBounds!.end, + samples.last!.observedAt + ) + } + + func testDenseOlderRangeIsDownsampledAndRejectsAnUnboundedRequest() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let samples = (0 ..< 7_000).map { minute in + let observedAt = start.addingTimeInterval(Double(minute) * 60) + return UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - minute % 100), + resetsAt: start.addingTimeInterval(7 * 86_400) + ) + } + let history = UsageHistory( + localDirectory: temporaryDirectory(), + installationID: "writer-a" + ) + _ = await history.load(legacySamples: samples) + let requested = DateInterval( + start: start, + end: samples.last!.observedAt.addingTimeInterval(1) + ) + let loadedView = await history.rangeView(for: requested) + let view = try XCTUnwrap(loadedView) + + XCTAssertEqual(view.resolution, .downsampled) + XCTAssertLessThanOrEqual(view.samples.count, 6_000) + XCTAssertEqual(view.samples.first?.observedAt, samples.first?.observedAt) + XCTAssertEqual(view.samples.last?.observedAt, samples.last?.observedAt) + let unboundedView = await history.rangeView(for: DateInterval( + start: start, + end: start.addingTimeInterval(85 * 86_400) + )) + XCTAssertNil(unboundedView) + } + + func testExplicitAndAutomaticSyncBoundAndPersistOfflineBackfill() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + let senderRoot = root.appendingPathComponent("sender", isDirectory: true) + let receiverRoot = root.appendingPathComponent("receiver", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let receiver = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await receiver.load() + _ = await receiver.connect(to: shared) + + let sender = UsageHistory( + localDirectory: senderRoot, + installationID: "sender" + ) + _ = await sender.load() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for day in 0 ..< 100 { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await sender.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(100 - day % 100), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + _ = await sender.connect(to: shared) + + _ = await receiver.synchronize() + var importedCount = jsonFiles( + for: "sender", + in: receiverRoot + ).count + XCTAssertLessThanOrEqual(importedCount, 32) + let newestSharedFile = try XCTUnwrap( + jsonFiles(for: "sender", in: shared) + .map(\.lastPathComponent) + .max() + ) + XCTAssertTrue( + jsonFiles(for: "sender", in: receiverRoot).contains { + $0.lastPathComponent == newestSharedFile + } + ) + + let reloaded = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await reloaded.load() + _ = await reloaded.connect( + to: shared, + performFullReconciliation: false + ) + var nextCount = jsonFiles(for: "sender", in: receiverRoot).count + XCTAssertLessThanOrEqual(nextCount - importedCount, 32) + importedCount = nextCount + + for pass in 2 ... 6 where importedCount < 100 { + _ = await reloaded.synchronizeIfDue( + at: Date().addingTimeInterval(Double(pass) * 3_600) + ) + nextCount = jsonFiles(for: "sender", in: receiverRoot).count + XCTAssertLessThanOrEqual(nextCount - importedCount, 32) + importedCount = nextCount + } + XCTAssertEqual(importedCount, 100) + } + + func testAutomaticSyncDoesNotRewriteUnchangedDailyFiles() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let history = UsageHistory( + localDirectory: root.appendingPathComponent("local", isDirectory: true), + installationID: "writer-a" + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: Date(timeIntervalSince1970: 1_700_000_000), + remainingPercent: 75, + resetsAt: Date(timeIntervalSince1970: 1_700_604_800) + )) + _ = await history.connect(to: shared) + let before = try writerManifestRevision(for: "writer-a", in: shared) + + _ = await history.synchronizeIfDue( + at: Date().addingTimeInterval(3_600) + ) + + XCTAssertEqual( + try writerManifestRevision(for: "writer-a", in: shared), + before + ) + } + + func testAutomaticSyncContinuesPastMalformedHistoryAndRevisitsItAfterRepair() async throws { + let root = temporaryDirectory() + let shared = root.appendingPathComponent("shared", isDirectory: true) + let receiverRoot = root.appendingPathComponent("receiver", isDirectory: true) + let senderRoot = root.appendingPathComponent("sender", isDirectory: true) + try FileManager.default.createDirectory( + at: shared, + withIntermediateDirectories: true + ) + let receiver = UsageHistory( + localDirectory: receiverRoot, + installationID: "receiver" + ) + _ = await receiver.load() + _ = await receiver.connect(to: shared) + + let sender = UsageHistory( + localDirectory: senderRoot, + installationID: "sender" + ) + _ = await sender.load() + let start = Date(timeIntervalSince1970: 1_700_000_000) + for day in 0 ..< 3 { + let observedAt = start.addingTimeInterval(Double(day) * 86_400) + _ = await sender.record(UsageSample( + observedAt: observedAt, + remainingPercent: Double(80 - day), + resetsAt: observedAt.addingTimeInterval(7 * 86_400) + )) + } + _ = await sender.connect(to: shared) + let damaged = try XCTUnwrap( + jsonFiles(for: "sender", in: shared).min { + $0.lastPathComponent < $1.lastPathComponent + } + ) + let original = try Data(contentsOf: damaged) + try Data("broken".utf8).write(to: damaged) + + let first = await receiver.synchronizeIfDue( + at: Date().addingTimeInterval(3_600) + ) + XCTAssertEqual( + first.errorMessage, + "Some synced history couldn’t be read." + ) + XCTAssertEqual(jsonFiles(for: "sender", in: receiverRoot).count, 2) + + try original.write(to: damaged, options: .atomic) + let second = await receiver.synchronizeIfDue( + at: Date().addingTimeInterval(7_200) + ) + XCTAssertNil(second.errorMessage) + XCTAssertEqual(jsonFiles(for: "sender", in: receiverRoot).count, 3) + } + func testMalformedFileKeepsValidHistoryAndReportsWarning() async throws { let root = temporaryDirectory() let now = Date(timeIntervalSince1970: 1_900_000) @@ -1471,18 +1822,24 @@ final class UsageHistoryTests: XCTestCase { remainingPercent: 80, resetsAt: now.addingTimeInterval(86_400) ) + let validLaterSample = UsageSample( + observedAt: now.addingTimeInterval(86_400), + remainingPercent: 70, + resetsAt: now.addingTimeInterval(2 * 86_400) + ) let history = UsageHistory( localDirectory: root, installationID: "writer-a" ) _ = await history.load() _ = await history.record(sample) - let writerDirectory = try XCTUnwrap( - writerDirectories(for: "writer-a", in: root).first - ) - try Data("broken".utf8).write( - to: writerDirectory.appendingPathComponent("broken.json") + _ = await history.record(validLaterSample) + let storedFile = try XCTUnwrap( + jsonFiles(for: "writer-a", in: root).min { + $0.lastPathComponent < $1.lastPathComponent + } ) + try Data("broken".utf8).write(to: storedFile) let reloaded = UsageHistory( localDirectory: root, @@ -1490,7 +1847,7 @@ final class UsageHistoryTests: XCTestCase { ) let state = await reloaded.load() - XCTAssertEqual(state.samples, [sample]) + XCTAssertEqual(state.samples, [validLaterSample]) XCTAssertEqual(state.errorMessage, "Some usage history couldn’t be read.") } @@ -1643,6 +2000,22 @@ final class UsageHistoryTests: XCTestCase { } } + private func writerManifestRevision( + for installationID: String, + in root: URL + ) throws -> UInt64 { + let writer = try XCTUnwrap( + writerDirectories(for: installationID, in: root).first + ) + let data = try Data( + contentsOf: writer.appendingPathComponent(".codex-limits-writer") + ) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + return try XCTUnwrap((object["revision"] as? NSNumber)?.uint64Value) + } + private func markerData(generation: Int, syncTarget: String) throws -> Data { try JSONSerialization.data(withJSONObject: [ "version": 2, diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 2068827..b3aef4f 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -3271,6 +3271,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.weeklyUsageRemaining) XCTAssertNil(reader.interval) + XCTAssertEqual(reader.menuBarText, "—") + XCTAssertEqual(reader.freshness, .unavailable) XCTAssertEqual(reader.evidence.reason, "Current allowance window unavailable") XCTAssertEqual(reader.guidanceTitle, "Current allowance window unavailable") XCTAssertTrue(reader.chart.allObserved.contains { diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift index 1f2ea65..97f830e 100644 --- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift @@ -4,6 +4,171 @@ import XCTest @MainActor final class UsageMonitorHistoryTests: XCTestCase { + func testUnselectedMenuSourceWaitsForDemandAndStopsAutomaticRefresh() async throws { + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ), + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_600), + remaining: 63 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + isEnabled: false, + menuBarSourceActive: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + var callCount = await source.callCount + XCTAssertEqual(callCount, 0) + + await monitor.setEnabled(true) + callCount = await source.callCount + XCTAssertEqual(callCount, 0) + + await monitor.refreshAccountIfStale() + callCount = await source.callCount + XCTAssertEqual(callCount, 1) + + await monitor.setMenuBarSourceActive(true) + callCount = await source.callCount + XCTAssertEqual(callCount, 2) + + await monitor.setMenuBarSourceActive(false) + await monitor.automaticRefresh() + callCount = await source.callCount + XCTAssertEqual(callCount, 2) + } + + func testDisabledMonitorDoesNoSourceWorkUntilEnabled() async throws { + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + isEnabled: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + await monitor.refresh() + let disabledCallCount = await source.callCount + XCTAssertEqual(disabledCallCount, 0) + + await monitor.setEnabled(true) + let enabledCallCount = await source.callCount + XCTAssertEqual(enabledCallCount, 1) + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "64%") + } + + func testDisplayBoundaryExpiresCodexWithoutAnotherSourceRead() async throws { + let now = Date() + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: now, + remaining: 64, + resetsAt: now.addingTimeInterval(0.15) + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + + await monitor.start() + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "64%") + let deadline = Date().addingTimeInterval(2) + while monitor.readerSnapshot.menuBarText != "—", Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "—") + XCTAssertEqual(monitor.readerSnapshot.freshness, .unavailable) + let callCount = await source.callCount + XCTAssertEqual(callCount, 1) + } + + func testDisablingMonitorCancelsAnInFlightFetchBeforePublication() async throws { + let source = DelayedFetchSource( + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + let refresh = Task { await monitor.refresh() } + while await source.callCount == 0 { + await Task.yield() + } + + await monitor.setEnabled(false) + await refresh.value + + XCTAssertFalse(monitor.isEnabled) + XCTAssertNil(monitor.readerSnapshot.account) + } + + func testHidingUnselectedCodexCancelsVisibleAccountWork() async { + let source = DelayedFetchSource( + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 64 + ) + ) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + menuBarSourceActive: false, + fetchUsage: { try await source.next() } + ) + await monitor.setVisible(true) + let refresh = Task { await monitor.refreshAccountIfStale() } + while await source.callCount == 0 { + await Task.yield() + } + + await monitor.setVisible(false) + await refresh.value + + XCTAssertNil(monitor.readerSnapshot.account) + XCTAssertFalse(monitor.isRefreshing) + } + func testSafetyBufferPolicyNormalizesInvalidValues() { XCTAssertEqual(SafetyBufferPolicy.normalized(nil), 3) XCTAssertEqual(SafetyBufferPolicy.normalized(.nan), 3) @@ -77,7 +242,7 @@ final class UsageMonitorHistoryTests: XCTestCase { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let fetchedAt = Date(timeIntervalSince1970: 1_900_000) + let fetchedAt = Date() defaults.set( try JSONEncoder().encode( StoredStateFixture( @@ -591,6 +756,50 @@ final class UsageMonitorHistoryTests: XCTestCase { ) } + func testExplicitRefreshSupersedesQueuedAutomaticRefresh() async { + let coordinator = IntegrationWorkCoordinator() + let gate = UsageMonitorCoordinatorGate() + let blocker = Task { + await coordinator.run(priority: .explicit) { + await gate.hold() + } + } + while !(await gate.started) { + await Task.yield() + } + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: Date(timeIntervalSince1970: 1_900_000), + remaining: 80 + ) + ]) + let monitor = UsageMonitor( + defaults: UserDefaults( + suiteName: "UsageMonitorHistoryTests-\(UUID().uuidString)" + )!, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + integrationWorkCoordinator: coordinator, + fetchUsage: { try await source.next() } + ) + let automatic = Task { await monitor.automaticRefresh() } + while !monitor.isRefreshing { + await Task.yield() + } + let explicit = Task { await monitor.refresh() } + try? await Task.sleep(for: .milliseconds(10)) + + await gate.release() + await blocker.value + await explicit.value + await automatic.value + + let callCount = await source.callCount + XCTAssertEqual(callCount, 1) + XCTAssertEqual(monitor.readerSnapshot.menuBarText, "80%") + } + func testTokenActivityRefreshesOnlyWhenAccountDataIsStale() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -638,11 +847,13 @@ final class UsageMonitorHistoryTests: XCTestCase { description: "first account evaluation started" ) let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -686,11 +897,13 @@ final class UsageMonitorHistoryTests: XCTestCase { description: "account evaluation started" ) let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -774,11 +987,13 @@ final class UsageMonitorHistoryTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let root = temporaryDirectory() + let fetchedAt = Date() let source = FetchSequence([ makeFetchResult( identity: "user@example.com", - fetchedAt: Date(timeIntervalSince1970: 1_900_000), - remaining: 80 + fetchedAt: fetchedAt, + remaining: 80, + resetsAt: fetchedAt.addingTimeInterval(7 * 86_400) ) ]) let monitor = UsageMonitor( @@ -969,7 +1184,7 @@ final class UsageMonitorHistoryTests: XCTestCase { requestCount = await requests.count XCTAssertEqual(requestCount, 0) - for graph in AnalyticsGraph.coreCases { + for graph in [AnalyticsGraph.usageRemaining, .tokenActivity] { var state = AnalyticsExplorationState.initial state.graph = graph await monitor.setLocalAnalyticsVisible( @@ -1772,6 +1987,59 @@ final class UsageMonitorHistoryTests: XCTestCase { ) } + func testEarlierHistoryLoadsOnlyAfterVisibleUserDemand() async throws { + let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let root = temporaryDirectory() + let currentAt = Date(timeIntervalSince1970: 9_000_000) + let oldAt = currentAt.addingTimeInterval(-30 * 86_400) + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: currentAt, + remaining: 80, + resetsAt: currentAt.addingTimeInterval(7 * 86_400) + ) + ]) + let monitor = UsageMonitor( + defaults: defaults, + historyDirectory: root, + startsAutomatically: false, + fetchUsage: { try await source.next() } + ) + await monitor.refresh() + let partition = try JSONDecoder().decode( + AccountHistoryPartition.self, + from: XCTUnwrap(defaults.data(forKey: "historyAccountPartition")) + ) + let olderWriter = UsageHistory( + localDirectory: root, + installationID: "older-fixture", + partition: partition + ) + _ = await olderWriter.load() + _ = await olderWriter.record(UsageSample( + observedAt: oldAt, + remainingPercent: 90, + resetsAt: oldAt.addingTimeInterval(7 * 86_400) + )) + + XCTAssertNil(monitor.historicalReaderSnapshot) + await monitor.setVisible(true) + await monitor.loadEarlierHistory( + exploration: .initial, + dispositions: [:] + ) + + XCTAssertEqual( + monitor.historicalReaderSnapshot?.chart.allObserved.first?.date, + oldAt + ) + await monitor.setVisible(false) + XCTAssertNil(monitor.historicalReaderSnapshot) + } + func testUnresolvableSavedSyncTargetKeepsDeletionPending() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -2018,6 +2286,7 @@ final class UsageMonitorHistoryTests: XCTestCase { identity: String, fetchedAt: Date, remaining: Double, + resetsAt: Date = Date(timeIntervalSince1970: 2_000_000), lifetimeTokens: Int64? = nil, lifetimeTokensObservedAt: Date? = nil, planType: String? = nil @@ -2026,6 +2295,7 @@ final class UsageMonitorHistoryTests: XCTestCase { account: .stable(identity: identity), fetchedAt: fetchedAt, remaining: remaining, + resetsAt: resetsAt, lifetimeTokens: lifetimeTokens, lifetimeTokensObservedAt: lifetimeTokensObservedAt, planType: planType @@ -2036,6 +2306,7 @@ final class UsageMonitorHistoryTests: XCTestCase { account: CodexAccountObservation, fetchedAt: Date, remaining: Double, + resetsAt: Date = Date(timeIntervalSince1970: 2_000_000), lifetimeTokens: Int64? = nil, lifetimeTokensObservedAt: Date? = nil, planType: String? = nil @@ -2047,7 +2318,7 @@ final class UsageMonitorHistoryTests: XCTestCase { name: "Codex", window: UsageWindow( remainingPercent: remaining, - resetsAt: Date(timeIntervalSince1970: 2_000_000), + resetsAt: resetsAt, durationMinutes: 10_080 ) ), @@ -2148,6 +2419,21 @@ private actor DelayedFetchSource { } } +private actor UsageMonitorCoordinatorGate { + private(set) var started = false + private var continuation: CheckedContinuation? + + func hold() async { + started = true + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + private final class BlockingUsageEvaluator: @unchecked Sendable { private let lock = NSLock() private let gate = DispatchSemaphore(value: 0) diff --git a/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift b/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift new file mode 100644 index 0000000..715a5a9 --- /dev/null +++ b/Tests/CodexLimitsTests/UsageOverviewSnapshotTests.swift @@ -0,0 +1,114 @@ +import ClaudeIntegrationCore +import Foundation +import XCTest +@testable import CodexLimits + +final class UsageOverviewSnapshotTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + func testOverviewContainsOnlyCurrentPeriodActualObservations() throws { + let window = window() + let current = point(-60, 65) + let chart = chart(window: window, segments: [[point(-8 * 86_400, 95), point(-600, 80), current, point(60, 60)]]) + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart, window: window, now: now)) + + XCTAssertEqual(overview.range, DateInterval(start: window.startsAt, end: window.resetsAt)) + XCTAssertEqual(overview.observedSegments, [[point(-600, 80), current]]) + XCTAssertEqual(overview.latest, current) + XCTAssertEqual(overview.target, chart.target) + XCTAssertFalse(overview.observedSegments.flatMap { $0 }.contains(point(3_600, 0))) + } + + func testCompactionKeepsSegmentEndpointsAndLatestWithinFixedBudget() throws { + let window = window() + let segments: [[UsageChartPoint]] = (0 ..< 20).map { segment in + let start = -10_000 + segment * 400 + return (0 ..< 100).map { index in + point(Double(start + index), Double(100 - index)) + } + } + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: now)) + XCTAssertEqual(overview.observedSegments.count, segments.count) + for (actual, original) in zip(overview.observedSegments, segments) { + XCTAssertEqual(actual.first, original.first) + XCTAssertEqual(actual.last, original.last) + XCTAssertTrue(actual.allSatisfy { original.contains($0) }) + } + XCTAssertEqual(overview.latest, segments.last?.last) + XCTAssertLessThanOrEqual(overview.observedSegments.flatMap { $0 }.count + overview.target.count + 1, 256) + + let isolated = (0 ..< 500).map { [point(Double(-600 + $0), 50)] } + let manyBreaks = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: isolated), window: window, now: now)) + XCTAssertTrue(manyBreaks.observedSegments.allSatisfy { $0.count == 1 }) + XCTAssertEqual(manyBreaks.latest, isolated.last?.last) + XCTAssertLessThanOrEqual(manyBreaks.observedSegments.count + manyBreaks.target.count + 1, 256) + } + + func testInvalidObservationsBreakLinesAndDerivedValuesAreRejected() throws { + let window = window() + let segments = [[point(-600, 80), point(-500, .nan), point(-400, 70), point(-300, 101), point(-200, 60)]] + let overview = try XCTUnwrap(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: now)) + XCTAssertEqual(overview.observedSegments, [[point(-600, 80)], [point(-400, 70)], [point(-200, 60)]]) + XCTAssertNil(UsageOverviewSnapshot(chart: chart(window: window, segments: segments, source: .derivedEstimate), window: window, now: now)) + XCTAssertNil(UsageOverviewSnapshot(chart: chart(window: window, segments: segments), window: window, now: window.resetsAt)) + } + + func testLatestOnlyOverviewNeverFabricatesEarlierHistoryOrUnknownMonthlyTarget() throws { + let current = observation(at: now.addingTimeInterval(-60)) + let overview = try XCTUnwrap(UsageOverviewSnapshot(observations: [], current: current, now: now, safetyBuffer: 7)) + XCTAssertEqual(overview.observedSegments, [[point(-60, 75)]]) + XCTAssertEqual(overview.latest, point(-60, 75)) + XCTAssertEqual(overview.target.last?.remaining, 7) + XCTAssertNil(UsageOverviewSnapshot(observations: [current], current: nil, now: now, safetyBuffer: 3)) + XCTAssertNil(UsageOverviewSnapshot(observations: [current], current: current, now: current.resetsAt, safetyBuffer: 3)) + + let monthly = observation(at: now.addingTimeInterval(-60), startsAt: nil) + let unknownStart = try XCTUnwrap(UsageOverviewSnapshot(observations: [], current: monthly, now: now, safetyBuffer: 3)) + XCTAssertTrue(unknownStart.target.isEmpty) + XCTAssertEqual(unknownStart.observedSegments, [[point(-60, 75)]]) + XCTAssertEqual(UsageOverviewSnapshot.historyReadStart(current: monthly, now: now), now.addingTimeInterval(-31 * 86_400)) + } + + func testProviderCorrectionSourceAndGapBoundariesSurviveOverview() throws { + let observations = [ + observation(at: now.addingTimeInterval(-90_000), remaining: 90), + observation(at: now.addingTimeInterval(-3_600), remaining: 80), + observation(at: now.addingTimeInterval(-3_000), remaining: 85), + observation(at: now.addingTimeInterval(-1_600), remaining: 70, source: "legacyCredits"), + observation(at: now.addingTimeInterval(-60), remaining: 60, source: "legacyCredits") + ] + let overview = try XCTUnwrap(UsageOverviewSnapshot(observations: observations, current: observations.last, now: now, safetyBuffer: 3)) + XCTAssertEqual(overview.observedSegments.map(\.count), [1, 1, 1, 2]) + } + + private func window() -> UsageWindow { + UsageWindow(remainingPercent: 65, resetsAt: now.addingTimeInterval(3_600), durationMinutes: 7 * 24 * 60) + } + + private func point(_ offset: TimeInterval, _ remaining: Double) -> UsageChartPoint { + UsageChartPoint(date: now.addingTimeInterval(offset), remaining: remaining) + } + + private func observation( + at date: Date, remaining: Double = 75, + startsAt: Date? = Date(timeIntervalSince1970: 1_800_000_000 - 6 * 86_400), + source: String = "creditUsagePercent" + ) -> AllowanceObservation { + AllowanceObservation(metric: "grok-weekly", observedAt: date, remainingPercent: remaining, + resetsAt: now.addingTimeInterval(3_600), startsAt: startsAt, source: source) + } + + private func chart(window: UsageWindow, segments: [[UsageChartPoint]], source: UsageValueSource = .account) -> UsageChartSnapshot { + UsageChartSnapshot( + observedSource: source, + target: [UsageChartPoint(date: window.startsAt, remaining: 100), UsageChartPoint(date: window.resetsAt, remaining: 3)], + currentProjection: [point(-60, 65), point(3_600, 0)], + reference: UsageChartReferenceSeries(source: .tokenEstimate, points: [point(-3_600, 99)]), + currentAllowanceReset: window.resetsAt, + allowanceWindows: [ + UsageAllowanceWindowSeries(resetsAt: window.startsAt, observedSegments: [[point(-86_400, 99)]]), + UsageAllowanceWindowSeries(resetsAt: window.resetsAt, observedSegments: segments) + ], currentRunsFaster: false, accessibilityValue: "" + ) + } +} diff --git a/docs/MEASUREMENT-CONTRACT.md b/docs/MEASUREMENT-CONTRACT.md new file mode 100644 index 0000000..a212606 --- /dev/null +++ b/docs/MEASUREMENT-CONTRACT.md @@ -0,0 +1,343 @@ +# Measurement contract + +This contract defines how Codex Limits labels facts, Freshness, Coverage, Confidence, and comparable work across supported Integrations. It applies to the reader snapshot, charts, Facts, Insights, tooltips, notifications, and tests. + +The product prefers no estimate to a weak estimate. + +## Source classes + +Every value has one source class: + +1. **Account fact** — returned by an Integration's supported account source. +2. **Local fact** — observed in an Integration's supported local records on this Mac. +3. **Derived estimate** — calculated from named account and local facts. + +The UI never merges these classes into one unexplained value. + +## Integration separation + +Every fact and Integration Snapshot carries its Integration ID, capability, observed time, source kind, and source version when available. + +- Never add, average, rank, or otherwise combine allowance percentages from different Integrations. +- Never convert OpenCode Local Activity into Account Allowance. +- Never infer support for one Integration Capability from another capability. +- Omit an Unsupported Capability. Use unavailable only when a supported capability cannot currently produce a value. +- Keep the existing Codex analytics engine isolated from Claude Code and Grok snapshots and histories, and from any future OpenCode snapshot. + +The `All` overview uses separate current-period thumbnails. Each includes only recorded account observations, the latest actual point, and a target when the period start is known. It excludes forecasts and token estimates, preserves gaps, and retains at most 256 display points. Claude/Grok overview reads cover at most 31 elapsed days (32 UTC files); detail history keeps its existing 84-day read bound. A single reading remains one point. + +## Account Allowance normalization + +An Integration may publish Account Allowance only when the source identifies the percentage orientation and allowance period. + +- A percentage must be finite and source-valid. Missing, null, malformed, or semantically unknown values are unavailable, never zero. +- Claude Code `used_percentage` is valid only inside `0...100`; convert it to `remaining = 100 - used`. Reject an out-of-range value. +- Grok `creditUsagePercent` is a used percentage. Preserve the finite original as `reportedUsedPercent`, apply the official `0...100` clamp, and convert it to remaining. Record `measurementSource` as `creditUsagePercent` or `legacyCredits`, with the CLI version when available. Use legacy `100 * used.val / monthlyLimit.val` only when both current fields are absent; a zero or missing legacy limit is unavailable. A present empty Cent object means zero, while a missing monetary field remains unavailable. The collector sends `_x.ai/billing` on the ACP wire; the internal handler name omits the underscore. +- A direct remaining percentage is validated without changing its orientation. +- Every allowance window carries its provider window ID or period type, observed time, reset time, and duration when known. +- Weekly and monthly Grok periods remain distinct. Retain a supplied `currentPeriod.start` or legacy `billingPeriodStart` only after supported finite-date and start-before-reset validation. A missing start remains absent; never invent a monthly start from a fixed duration. An unknown period type is unavailable rather than relabeled as weekly. +- A missing secondary window does not invalidate another independently valid window. +- OpenCode Local Token Activity is not Account Allowance and never passes through this normalization. + +## Freshness and allowance lifetime + +Freshness describes age and current source availability, not accuracy or Confidence. + +The OpenCode row is a future contract. Freshness windows do not define polling schedules; Grok polls every ten minutes only while selected for the menu bar, with failure backoff and a thirty-second launch floor. + +| Integration | Fresh window | +|---|---:| +| Codex | At most 15 minutes old | +| Claude Code | At most 30 minutes old | +| Grok | At most 30 minutes old | +| OpenCode | At most 15 minutes old | + +Reader-facing source states are: + +| State | Meaning | +|---|---| +| `Fresh` | A valid observation inside its Freshness window and, for allowance, before its known reset. | +| `Stale` | A valid observation outside its Freshness window but still before the same known reset. | +| `Expired` | A valid allowance observation whose known reset passed without a post-reset observation. Its old percentage is withheld. | +| `Unavailable` | No compatible valid observation exists. | + +Claude Code always shows `Last observed` because its `statusLine` source is event-driven. When a supported source fails, retain the last valid snapshot only while it remains meaningful, show the current actionable source error, and never replace a failed or absent reading with zero. A known reset boundary takes precedence over the Freshness window: no numeric allowance crosses it. + +Concurrent event-driven observations are ordered by receive time captured before parsing. Atomic persistence must not allow an older Claude relay process that finishes later to replace a newer observation. + +## Claude Code and Grok allowance history + +Each provider has its own Usage remaining chart backed only by recorded allowance observations. Claude's seven-day and five-hour windows are separate series; Grok's weekly and monthly periods are separate series. Every point retains its observation time, period or window, reset, source provenance, and provider-reported start when available. A cached snapshot from before history support may seed one real point at its original observation time. It never creates earlier points or a complete past period. + +A source or period change, reset, or detected correction breaks a comparable interval. An increase in remaining percentage beyond the shared 0.1 percentage-point rounding tolerance is a correction. Do not connect across these breaks. Missing observations remain gaps; token activity, local sessions, costs, and monetary balances never act as allowance proxies. + +A current allowance estimate requires at least two compatible observations spanning at least 60 seconds, a latest observation inside the provider's 30-minute Freshness window and before reset, and no intervening gap over 30 minutes, reset, source change, or correction. The estimate stays within the same known allowance period and is labeled as an estimate. When these gates fail, show recorded facts and the reason more observations are needed. Do not borrow Codex token-based guidance, workload comparisons, or Confidence from another Integration. + +## Primary Codex allowance + +The weekly Codex allowance is the primary allowance. + +- Select the Codex window whose `windowDurationMins` is `10080`. +- Show its Usage remaining in the menu bar, current-state header, Runway, Suggested Pace, and default Usage remaining chart. +- Do not replace it with a five-hour window because that window has a lower percentage. +- Show five-hour and model-specific windows as Other limits in Facts. +- If no weekly window is returned, show `Weekly usage unavailable`. Do not substitute another window without naming it. + +Every allowance-derived metric carries the selected limit ID, duration, start, and reset time. + +## Account Token Activity + +Account Token Activity is the primary weekly token total. Use the strongest available method in this order: + +1. **Observed lifetime delta** — subtract two monotonic `summary.lifetimeTokens` readings that bound the same account and interval. +2. **Exact daily sum** — sum complete account daily buckets only when their calendar boundaries match the selected interval. +3. **Partial daily sum** — show complete daily buckets inside the interval as a factual partial value. Do not scale partial days or call the result a weekly total. +4. **Unavailable** — withhold the total when no method above applies. + +An observed lifetime delta is valid only when: + +- both readings belong to the same local account partition; +- the counter did not decrease; +- both interval boundaries meet the boundary rules below; +- no account change occurred between the readings. + +Daily buckets may seed a historical chart, but they never become observed allowance readings. + +## Account facts + +Facts may show these values when the account API returns them: + +- Lifetime tokens +- Peak daily tokens +- Longest running turn +- Current streak +- Longest streak +- Credits balance or unlimited credits +- Spend-control limit, Usage remaining, and reset time + +These are Account facts. They do not need Confidence. They do need source, fetched time, and an unavailable state. + +## Local Activity source boundary + +Issue `Prove read-only Local Activity ingestion and Coverage` owns the source decision before Local Token Activity ships. + +Until that spike is complete: + +- do not assume that a separate app-server connection receives live events from Tasks owned by another Codex process; +- do not resume, load, start, stop, or take ownership of a user Task to observe it; +- treat supported read-only app-server projections as the preferred metadata source; +- treat incrementally tailed local Codex records as a candidate source for token, turn, tool, and timing facts; +- record source capability and CLI version with every normalized event. + +If no safe read-only source exists for a fact, the fact is unavailable. + +## OpenCode Local Activity + +OpenCode Local Activity is deferred from v1 because the tested supported local-server process failed the memory and initialization-write budgets. The rules below are the acceptance contract for a future supported source; they do not authorize the rejected server collector. A future source never requests messages or parts. + +- The rolling seven-day interval is exactly 604,800 seconds ending now. +- Local Token Activity is the sum of finite, non-negative input, output, reasoning, cache-read, and cache-write counters only after fixtures prove these categories are disjoint cumulative values. +- A root session has no parent ID. Overview session count includes roots only; detail may show bounded descendants separately. +- Parent and child token or cost totals are not added until fixtures prove that parent totals exclude descendants. +- Repeated cumulative snapshots deduplicate by `(Integration ID, session ID)` and retain the newest compatible counter state. +- A counter decrease after compaction, fork, archive, deletion, or schema change creates a source break; do not produce a negative delta or silently join both sides. +- Missing or invalid cost is unavailable, not zero. Valid cost is labeled `OpenCode local estimated cost` and remains distinct from provider billing. +- Session provider/model describes only the currently saved session selection. It does not prove per-response or whole-session model attribution. +- Production collection remains unavailable until a supported source enforces accepted range and count bounds before returning session data and passes the process, memory, disk-write, and endurance budgets. + +## Time boundaries + +### Rolling ranges + +`24 hours`, `3 days`, `7 days`, `4 weeks`, and `12 weeks` end at the current instant and use exact elapsed durations of 86,400, 259,200, 604,800, 2,419,200, and 7,257,600 seconds. A delayed observation does not move a rolling range into the past. + +### Machine-local time + +Reader-facing dates and clock labels use the Mac's current time zone when rendered. A time-zone or daylight-saving change changes local labels, not the underlying elapsed interval. + +An interval is: + +- **Tightly bounded** when the closest account readings are no more than 15 minutes from both boundaries. +- **Loosely bounded** when both readings are no more than 60 minutes from the boundaries. +- **Unbounded** when either reading is farther away or missing. + +For allowance movement: + +- a gap of no more than 30 minutes between account readings supports High Coverage; +- a gap over 30 minutes and no more than 6 hours lowers Coverage to Partial; +- a gap over 6 hours makes comparable allowance movement unavailable; +- any gap that may contain an unknown reset or correction makes the interval unbounded. + +A known scheduled reset, banked reset, account change, or detected correction always splits the interval. + +## Coverage + +Coverage says how much of the required source data was observed. It does not mean accuracy. + +Reader-facing Coverage states are: + +| State | Meaning | +|---|---| +| `Complete` | Every required source and boundary is present, with no known gap or ambiguity. | +| `High` | At least 80% of aligned activity is represented and every required boundary is tight. | +| `Partial` | Useful evidence exists, but coverage is between 50% and 79%, a boundary is loose, or a named source is missing. | +| `Low` | Less than 50% is represented or a material gap prevents a dependable conclusion. | +| `Unavailable` | The required source, identity, token definition, or time boundary cannot be reconciled. | +| `Not applicable` | The metric has no meaningful coverage denominator, such as an interval with no activity. | + +Every state other than Complete names at least one reason, such as: + +- `Account boundary is 42 minutes late` +- `Local Tasks are missing` +- `Activity from another device is possible` +- `Token definitions do not align` +- `Unknown reset or correction` +- `Codex version does not expose this field` + +### Numeric Local Coverage + +Numeric Local Coverage is shown only when the source spike proves that Account Token Activity and Local Token Activity use compatible token definitions for the active Codex version and both values cover the same interval. + +For aligned values: + +`Local Coverage = Local Token Activity / Account Token Activity` + +Rules: + +- When both totals are zero, Coverage is Not applicable. +- When account activity is zero but local activity is positive, numeric Coverage is unavailable. +- When local activity is more than 2% above account activity, numeric Coverage is unavailable and the UI says `Account and local totals do not align`. +- A difference of at most 2% may be treated as rounding and clamped to 100%. +- Numeric Coverage describes the share of Account Token Activity visible in local records. It does not prove that local records explain account billing. + +### Reset Detail Coverage + +Reset Detail Coverage uses the authoritative reset count and returned available detail: + +- `Complete` when detail count equals the authoritative count. +- `Partial` when detail count is greater than zero and lower than the count. +- `Unavailable` when the count is greater than zero and no detail is returned. +- `Not applicable` when the authoritative count is zero. + +## Confidence + +Confidence says how strongly the observed evidence supports a derived estimate or Insight. + +| State | Product behavior | +|---|---| +| `High` | Show the estimate or Insight. Coverage is Complete or High, the interval is tightly bounded, and no material comparability warning applies. | +| `Medium` | Show the estimate with its range and named caveat. The interval is still bounded and the conclusion remains useful. | +| `Low` | Withhold the estimate or Insight. Show the observed facts and the reason more evidence is needed. | +| `Unavailable` | Do not calculate the result. | + +Direct Account facts and Local facts show provenance and freshness instead of artificial Confidence. + +The engine, not the view, owns Confidence and its reasons. Thresholds are versioned policy values and have deterministic tests. + +## Comparable work + +Two intervals are comparable only when all these gates pass: + +- both intervals belong to the same account partition; +- both use the weekly Codex allowance; +- both are bounded; +- neither contains a reset, account change, unknown correction, or counter decrease; +- both have non-zero Account Token Activity; +- Local Coverage is at least 50% when workload mix is part of the comparison; +- the dominant model family and reasoning level are known; +- model, reasoning, and cached-input shares differ by no more than 20 percentage points; +- the product can name every reason that lowers comparability. + +Comparability is: + +- **High** when Local Coverage is at least 80%, both intervals are tightly bounded, and each observed workload-mix share differs by no more than 10 percentage points. +- **Medium** when Local Coverage is at least 50%, the intervals are at least loosely bounded, and each share differs by no more than 20 percentage points. +- **Not comparable** otherwise. + +Low-comparability conclusions are withheld. + +## Reference Baseline + +The default Reference Baseline is the median Allowance Intensity of the previous four complete, High-comparability weekly windows. + +- Use exactly four eligible windows. +- If fewer than four exist, show `Not enough comparable weeks`. +- A user-pinned period must pass at least Medium comparability. +- Pinning a period does not override reset, identity, boundary, or token-definition failures. +- Store the baseline interval IDs and policy version with the derived result. + +Allowance Intensity divides observed weekly Account Movement by aligned Account Token Activity. Equivalent Capacity extrapolates from that intensity and always remains an estimate. + +## Account partitions + +Analytics History never mixes signed-in accounts. + +- Read account state before joining new observations to history. +- When an email is available, derive an on-device keyed fingerprint and never persist the email as the partition key. +- When stable identity is unavailable, start an isolated unknown-account partition after every observed auth transition. +- A plan change does not create a new partition, but it splits comparable intervals. + +Claude Code and Grok snapshots and observation histories remain in separate Local Installation Partitions and are never joined or synchronized. Neither source supplies a stable account identity, so these histories describe observations on this installation and cannot establish continuity through an unobserved provider account change. Current snapshots remain bounded at 64 KiB; retained history is separate. A future OpenCode integration follows the same partition rule and keeps only the bounded rolling seven-day cache defined by the multi-integration PRD. + +## Retention and Bounded Working Set + +Retention describes the canonical records kept on disk. It never authorizes loading every retained record into resident memory. + +- Reader snapshots contain only the current value, the selected visible range, and bounded summaries required by the visible surface. +- Ordinary refresh and periodic sync do not enumerate or decode all retained Codex history. +- A range query returns a bounded point count or a bounded aggregate resolution. +- Source detail caches are released when their capability becomes hidden. +- Claude Code and Grok retain compact provider-local allowance observations until explicit deletion. Their active history view is bounded to the latest 84 days; this bound does not delete older canonical records or authorize eager full-history reads. Disabling preserves history, and a prior latest-only snapshot seeds at most its own observation. +- The provider-local reader opens at most 85 direct UTC daily journal paths, reads at most 4 MiB per file and 32 MiB total, limits each record to 512 bytes and the decoded working set to 200,000 records, and reports a history failure for malformed committed records or exceeded bounds. It does not silently downsample. Appends are cross-process locked, contain at most 64 records, and inspect only a bounded tail for interrupted-write recovery and duplicate suppression. +- Tests compare short and multi-year fixtures and fail when resident memory or ordinary refresh time grows proportionally with retained history. +- The Codex reader working set contains at most 6,000 samples from the latest 84 days. The latest eight days remain full resolution; older data keeps the first and last sample per hourly/reset bucket plus explicit comparison breaks. +- The default 84-day working set does not redefine retained-history bounds. Selecting an older interval reads at most 84 days and returns at most 6,000 exact or explicitly downsampled samples; it never requires all retained samples to become resident. A cold default or range read considers at most 32 writer partitions, reads at most 256 daily files and 8 MiB, and exposes a partial-history issue when a bound is reached. +- A bounded Codex sync uses the durable manifest and cursor contract in ADR-0014. One periodic pass examines at most 32 calendar-day candidates and reads at most 32 daily files across all writers, prioritizes recent changes, persists its round-robin backlog position, and eventually merges every changed daily file. Truncating to the newest files without eventual backfill is forbidden. An explicit new-folder connection may do one full reconciliation outside the main actor. + +## Delete non-Codex Integration data + +`Delete integration data` disables the selected non-Codex Integration, cancels its source work, and removes every app-owned snapshot, retained allowance observation, Derived Record, cache, and Codex Limits-owned source configuration for that Integration. It preserves source records owned by the integrated product and does not rebuild automatically. Re-enabling the Integration explicitly starts a new local collection boundary. + +## Delete analytics history + +`Delete analytics history` means all Codex Analytics History owned by Codex Limits; Claude Code and Grok use their separate Integration deletion actions: + +- all local Derived Records; +- Codex-assisted Insight results; +- Analytics Overhead records; +- account usage samples in the selected sync folder; +- records written by every installation in that sync folder. + +Preferences, notification settings, and Codex source records remain. + +Deletion creates a new empty sync generation so another Mac cannot republish older history. Each installation that observes the generation discards older local analytics before it publishes again. + +If the selected sync folder is unavailable, the product must not claim that deletion completed. It prevents older synced records from being imported, keeps a pending deletion state, and offers retry. + +The app does not rebuild deleted history automatically. A separate explicit `Rebuild available history` action may read only source data that still exists. New observations after deletion belong to the new generation. + +## Codex-assisted availability + +`Analyze with Codex` is visible only when `model/list` advertises: + +- GPT-5.6 Luna; +- Medium reasoning for that exact model; +- an account state that can run the request. + +If any condition is missing or model availability cannot be checked, hide the action. Do not fall back to GPT-5.5, Terra, Sol, another reasoning level, or the analyzed Task model. + +Metadata-only Analysis uses a closed payload allowlist. Source-backed Analysis sends only the categories and scope accepted in its current preflight. The analysis Task cannot use tools, read additional files, or change the workspace. + +## Reader rules + +- Show the source beside a value when sources may disagree. +- Show the observed interval for every derived value. +- Show raw facts before estimates. +- Use `Not enough data` or a specific reason instead of a Low-confidence number. +- Never call Coverage accuracy. +- Never call Confidence certainty. +- Never call Account Token Activity a token allowance. +- Never call Local Coverage billing coverage. +- Never show a pre-reset allowance percentage as current after its known reset. +- Never call an event-driven cache refresh a live account refresh. +- Never call a missing or invalid OpenCode cost zero. diff --git a/docs/PRODUCT-LANGUAGE.md b/docs/PRODUCT-LANGUAGE.md new file mode 100644 index 0000000..b8c2241 --- /dev/null +++ b/docs/PRODUCT-LANGUAGE.md @@ -0,0 +1,125 @@ +# Product language + +Codex Limits uses clear, direct English. These rules apply to every label, tooltip, chart, notification, and insight. + +## Orwell’s six rules + +1. Use literal words. Avoid familiar metaphors and figures of speech. +2. Use a short word when it says the same thing as a long word. +3. Cut every word that adds no meaning. +4. Use active voice. +5. Prefer everyday English to jargon or foreign phrases. +6. Break a rule when following it would make the text harsh, false, or unclear. + +## Product rules + +- Name the quantity: `remaining`, `used`, `tokens`, or `percentage points`. +- Use Codex’s label `Usage remaining` for the primary allowance percentage. +- Separate account facts, local facts, and estimates. +- State uncertainty instead of hiding it. +- Name the source when two sources can disagree. +- Describe what changed; do not invent a cause. +- Use one canonical domain term for one concept. +- Put the action first in buttons. +- Keep tooltips to one fact or consequence. +- Do not call local activity billing, cost, waste, or efficiency. +- Do not claim that OpenAI changed a limit when the product only observed a change in intensity. +- Describe a usage deviation in `Insights`; do not call it an anomaly or send an alert. +- Use the weekly Codex window for the primary `Usage remaining`; name every other window. +- Withhold a Low-confidence estimate and say what data is missing. +- Do not call a partial sum of daily token buckets a weekly total. + +## Time labels + +Rolling ranges end now. Show their dates and clock labels in the Mac's current time zone; daylight-saving and time-zone changes do not change the elapsed range. + +## Navigation labels + +- `All` — strongest supported facts from every Enabled Integration. +- `Codex`, `Claude Code`, `Grok` — v1 Integration detail destinations shown only while enabled. +- `OpenCode` — reserved future destination; do not show them until a supported collector ships and the Integration is enabled. +- `Graphs` — Usage remaining, Token activity, Usage per token, and Concurrency charts. +- `Facts` — account facts, banked resets, other limits, and Usage receipts. +- `Insights` — structured observations and recommendations. + +The current-state header remains visible while these views change. + +## Integration settings + +Use only the state that tells the user what can happen next: + +- `Checking` — one explicitly requested compatibility or setup check is running. +- `Not found` — the user-managed Integration is not available on this Mac. +- `Set up` — the Integration is available but needs an explicit setup action. +- `Waiting for data` — setup succeeded but the source has not produced its first observation. +- `Ready` — the Integration can produce its supported facts; keep this status inside Settings. +- `Update required` — the installed CLI cannot provide the accepted source contract. +- A provider-specific error such as `Billing unavailable` — a shipped supported source failed and the user can retry or change setup. Do not expose errors for deferred Integrations. + +Use `Last observed` for Claude Code and any other event-driven source. Use `Stale` only after the source-specific Freshness window and before the same known reset. Use `Expired` internally; reader copy should say `New usage observation needed` rather than exposing the implementation term. Use `Experimental` for Claude Code and `Beta` for Grok in Settings and the Integration detail header, not beside every value. + +## Menu bar metric labels + +The shipped v1 picker names both source and quantity: + +- `None` +- `Codex — Weekly usage remaining` +- `Claude Code — 7-day usage remaining` +- `Grok — Current-period usage remaining` + +Reserved future label, shown only after the corresponding collector ships: + +- `OpenCode — 7-day local tokens` + +Do not shorten picker labels to a bare Integration name. The compact menu bar itself may use `%`, `K`, or `M` once the picker and accessibility label establish the quantity. + +## Source-specific actions + +- Codex and Grok may use `Refresh` when the action starts a source read. Grok respects its thirty-second launch floor, including explicit actions. A future OpenCode surface may use it after its collector ships. +- Claude Code uses `Check for new observation` only to re-read the relay cache. Supporting copy says `Usage updates during Claude Code activity`. +- Use `Delete Claude Code data…` for the destructive Settings action and `Delete integration data` in its confirmation. The message must say that Claude Code's own data is not deleted. +- Use `Delete Grok data…` for Grok’s Settings action; explain that it removes only Codex Limits data and preserves Grok Build’s files and login. +- Use `Check again` for compatibility or setup recovery. +- Use `Locate…` when the user needs to choose an executable. +- Never show `Refresh all` in v1. + +Grok names the returned weekly or monthly period. When the source marks unified billing, describe a shared Grok usage pool; never imply that its percentage measures only Grok Build activity. Optional plan, prepaid, and pay-as-you-go facts keep their own labels. Show CLI-version provenance in the Grok detail. + +Claude Code and Grok use `Usage remaining` for their recorded burndown charts. Name Claude's `7-day` and `5-hour` windows separately and name Grok's returned weekly or monthly period. Describe points as `Recorded` and projections as estimates; never imply that a one-point cache reconstructs an earlier period. An empty or single-observation chart explains that more observations are needed. Retained history describes this Mac's observations and does not imply a verified account identity or cross-device coverage. Deletion copy includes recorded usage history. + +In Claude Settings and the setup confirmation, `Waiting for data` explains that usage data is available on eligible Pro and Max accounts and appears after the first response in a session. The setup confirmation says Codex Limits changes the user status line and that project or managed settings can override it. Do not repeat plan eligibility in the menu bar or beside every value. + +## Empty and expired states + +- No Enabled Integrations: `No integrations enabled` with `Open Settings`. +- Enabled but not configured: use the Integration's specific setup state and one action. +- Claude without a first observation: the workspace uses `Use Claude Code to record usage` and `Usage appears after the first response in a session`; Settings adds the eligible Pro/Max constraint. +- Known reset passed: `New usage observation needed`; do not repeat the old percentage. +- A future OpenCode surface with missing cost omits the cost or says `Estimated cost unavailable`; it never displays `$0`. + +Background refresh does not replace valid content with `Loading`, `Reading usage`, or a global progress message. Keep cached values visible and attach progress only to an explicit action. + +History sync may show `Backfilling history` in Settings while its bounded cursor is visiting older daily files. Do not show that state in the menu bar or over cached charts, and do not say `Up to date` unless the current generation has no known backlog. + +## Examples + +| Avoid | Use | +|---|---| +| `37% left` or `37% allowance remaining` | `Usage remaining · 37%` | +| `You're burning through your quota` | `Usage increased faster than your baseline` | +| `Token efficiency` | `Allowance used per 1M local tokens` | +| `Workload cost` as a visible chart label | `Usage per token` | +| `Oldest reset expires` | `Next known expiry` | +| `3 resets available` when only one expiry is known | `3 banked resets · 1 expiry known` | +| `AI-powered analysis` | `Analyze with Codex` | +| `We detected hidden usage` | `Account and local totals differ` | +| `Your limit got worse` | `Comparable work used 1.3× more allowance` | +| `Usage anomaly detected` | `Usage increased faster than your baseline` | +| `Low confidence · 3.2 days` | `Not enough data · Account gap over 6 hours` | +| `Combined usage · 42%` | Separate Integration values | +| `OpenCode billing cost` | `OpenCode local estimated cost` | +| `Claude live usage` | `Claude Code · Last observed 8 min ago` | +| `Claude Code · Refresh` | `Usage updates during Claude Code activity` | +| `37% · Stale` after its reset | `New usage observation needed` | +| `OpenCode cost · $0` when absent | `Estimated cost unavailable` | +| `Menu bar source · Claude Code` | `Claude Code — 7-day usage remaining` | diff --git a/docs/adr/0006-user-selected-folder-for-shared-usage-history.md b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md index c01385e..39410cf 100644 --- a/docs/adr/0006-user-selected-folder-for-shared-usage-history.md +++ b/docs/adr/0006-user-selected-folder-for-shared-usage-history.md @@ -1,5 +1,7 @@ # User-selected folder for shared usage history -The app stores usage history locally as versioned daily JSON files and optionally replicates those files across Macs through a dedicated user-selected folder, such as one in iCloud Drive. This keeps the same lightweight format on both sides; a shared SQLite database is rejected because file synchronization cannot coordinate its transactions and auxiliary files safely, while a separate local database would add a second persistence model without benefiting the small 90-day dataset. CloudKit is unavailable to the app's ad-hoc local builds. +The app stores account usage history locally as versioned daily JSON files and optionally replicates those files across Macs through a dedicated user-selected folder, such as one in iCloud Drive. This keeps the same lightweight format on both sides; a shared SQLite database is rejected because file synchronization cannot coordinate its transactions and auxiliary files safely, while a separate local database would add a second persistence model without benefiting the compact dataset. CloudKit is unavailable to the app's ad-hoc local builds. ADR-0010 supersedes this ADR's original 90-day retention and disconnect-only deletion behavior. -The app initializes only an empty folder or joins a folder carrying its supported format marker, so it never treats an arbitrary nonempty directory as sync data. Each sync folder represents one Codex account and contains usage history only; preferences, credentials, and device state remain local. The folder must be private and not shared with other people. The JSON remains readable and has no app-level encryption because it contains no credentials or content, while synchronizing and recovering an encryption key would add a new data-loss risk. When a Mac joins, its existing 90-day history is merged with the history already in the folder without replacing either side. An exact tuple of observation time, remaining percentage, and window reset identifies a usage sample; exact copies are deduplicated, while readings made at different times remain distinct. Each installation has a random local identifier and writes only files belonging to that identifier, preventing concurrent Macs from overwriting one another without exposing hardware identity. The app ignores samples older than 90 days; an installation removes only its own expired daily files and never deletes another installation's files. Stopping sync only disconnects the folder: the merged local history and the folder's contents remain intact. Folder failures never replace valid local history or interrupt the main usage display; the app keeps the folder selected, retries later, and reports sync status only in settings. Valid files continue to merge when one history file is malformed, while that file is left untouched and reported in settings. An unsupported folder-format marker prevents all writes so an older app cannot damage newer data. Sync runs only during the app's existing refresh events and may therefore lag by up to the ten-minute background refresh interval. Each Mac imports independently; the app neither tracks delivery to another Mac nor claims that both devices are up to date. Reading current usage from Codex and exchanging history produce independent results, so either can succeed when the other fails. +The app initializes only an empty folder or joins a folder carrying its supported format marker, so it never treats an arbitrary nonempty directory as sync data. Each sync folder represents one Codex account and contains account usage history only; preferences, credentials, device state, local Task analytics, and Source Content remain local. The folder must be private and not shared with other people. The JSON remains readable and has no app-level encryption because it contains no credentials or content, while synchronizing and recovering an encryption key would add a new data-loss risk. When a Mac joins, its existing history is merged with the current sync generation without replacing either side. An exact tuple of observation time, remaining percentage, and window reset identifies a usage sample; exact copies are deduplicated, while readings made at different times remain distinct. Each installation has a random local identifier and writes only files belonging to that identifier, preventing concurrent Macs from overwriting one another without exposing hardware identity. History has no automatic age cutoff. + +Stopping sync only disconnects the folder: the merged local history and the folder's contents remain intact. The separate `Delete analytics history` action defined in ADR-0010 removes Codex Limits history from both locations and advances the sync generation so older files cannot be imported again. Folder failures never replace valid local history or interrupt the main usage display; the app keeps the folder selected, retries later, and reports sync status only in settings. Valid files continue to merge when one history file is malformed, while that file is left untouched and reported in settings. An unsupported folder-format marker prevents all writes so an older app cannot damage newer data. Sync runs only during the app's existing refresh events and may therefore lag by up to the ten-minute background refresh interval. Each Mac imports independently; the app neither tracks delivery to another Mac nor claims that both devices are up to date. Reading current usage from Codex and exchanging history produce independent results, so either can succeed when the other fails. diff --git a/docs/adr/0007-local-only-analytics.md b/docs/adr/0007-local-only-analytics.md new file mode 100644 index 0000000..7f097f4 --- /dev/null +++ b/docs/adr/0007-local-only-analytics.md @@ -0,0 +1,3 @@ +# Local-only analytics + +Codex Limits may analyze Codex information already accessible on the user’s machine without a separate analytics opt-in, but Codex-derived data, computation, and derived history remain on-device and are not transmitted as product telemetry. Raw prompts, responses, code, paths, commands, and tool output may be read locally when needed, but are not duplicated into the analytics store; only compact derived records are retained. This preserves complete cross-task analytics without introducing a cloud data boundary; unrelated operating-system permissions, such as notification authorization, remain explicit. diff --git a/docs/adr/0008-user-initiated-codex-assisted-insights.md b/docs/adr/0008-user-initiated-codex-assisted-insights.md new file mode 100644 index 0000000..ebd3b5d --- /dev/null +++ b/docs/adr/0008-user-initiated-codex-assisted-insights.md @@ -0,0 +1,7 @@ +# User-initiated Codex-assisted insights + +Deterministic analytics remains Local-only, but a user may explicitly invoke a separately labeled `Analyze with Codex` action to generate a Codex-assisted Insight from bounded evidence. The action must disclose that it sends a request to Codex and consumes allowance, and it must never run automatically. Metadata-only Analysis starts directly after the explicit action; Source-backed Analysis first shows a short preflight identifying the content categories that will be sent. + +The product reads `model/list` before it exposes the action. It shows `Analyze with Codex` only when the catalog advertises the exact GPT-5.6 Luna Medium profile. If that profile is missing or catalog lookup fails, the action is absent. The product never falls back to GPT-5.5 Medium, Terra, Sol, another reasoning level, or the analyzed Task model. A stronger retry requires a new user action and an explicitly advertised profile. + +The analysis Task receives only the bounded payload. It has no tools, cannot read more files, cannot change the workspace, and cannot control another Task. This explicit exception to ADR-0007 preserves user intent, privacy expectations, and control over analytics overhead. diff --git a/docs/adr/0009-keep-account-control-read-only.md b/docs/adr/0009-keep-account-control-read-only.md new file mode 100644 index 0000000..aa32af3 --- /dev/null +++ b/docs/adr/0009-keep-account-control-read-only.md @@ -0,0 +1,3 @@ +# Keep account control read-only + +Codex Limits reads account and activity data, calculates metrics, shows guidance, schedules local reminders, and runs user-requested Codex analysis, but it does not redeem resets, change Codex settings, or control tasks. Although the app-server exposes reset redemption, the product uses a Reset Reminder instead; this avoids hidden account changes and preserves user control. diff --git a/docs/adr/0010-retain-analytics-history-until-deletion.md b/docs/adr/0010-retain-analytics-history-until-deletion.md new file mode 100644 index 0000000..7d992e8 --- /dev/null +++ b/docs/adr/0010-retain-analytics-history-until-deletion.md @@ -0,0 +1,9 @@ +# Retain analytics history until user deletion + +Codex Limits keeps compact, account-partitioned Derived Records without a time limit until the user chooses `Delete analytics history`; Source Content is never copied into this store. This supersedes the 90-day retention rule in ADR-0006 for local account samples because long baselines, comparable-workload analysis, and personal trends lose value when old observations disappear. The existing user-selected folder remains limited to account usage samples; deep Task Tree, agent, model, Source Content-derived, and Codex-assisted records stay on the current Mac unless a separate decision expands that boundary. + +Unlimited retention applies to the canonical on-disk store, not to resident memory or ordinary refresh work. Reader snapshots, chart queries, sync reconciliation, and analytics calculations use bounded ranges, indexes, or summaries; they do not keep or repeatedly decode every retained record. Long retention must not make steady-state RSS or ordinary refresh time grow proportionally with history age. + +`Delete analytics history` removes the whole history owned by Codex Limits: every local Derived Record and every supported account-history generation in the selected sync folder, including records written by another installation. It preserves preferences. Deletion advances an empty sync generation so an offline Mac cannot restore an older generation later. If the selected folder is unavailable, the app keeps deletion pending, blocks imports from older generations, and does not claim that deletion is complete. + +The app does not rebuild deleted history automatically. A separate explicit rebuild action may read only Codex sources that still exist. The product must not promise full recovery. diff --git a/docs/adr/0011-weekly-allowance-is-primary.md b/docs/adr/0011-weekly-allowance-is-primary.md new file mode 100644 index 0000000..34a6e11 --- /dev/null +++ b/docs/adr/0011-weekly-allowance-is-primary.md @@ -0,0 +1,5 @@ +# Weekly allowance is primary + +The `10080`-minute Codex allowance window is the product's primary window. It owns the menu-bar percentage, current-state header, Runway, Suggested Pace, default `Usage remaining` graph, and weekly Account Token Activity. Five-hour and model-specific windows remain visible as named Other limits. + +If the account source does not return a weekly window, Codex Limits shows the weekly state as unavailable. It never substitutes another window or relabels another percentage as weekly. This preserves the meaning of every value and keeps historical comparisons on the same boundary. diff --git a/docs/adr/0012-demand-driven-bounded-integration-collection.md b/docs/adr/0012-demand-driven-bounded-integration-collection.md new file mode 100644 index 0000000..b7f8fbf --- /dev/null +++ b/docs/adr/0012-demand-driven-bounded-integration-collection.md @@ -0,0 +1,17 @@ +# Demand-driven bounded Integration collection + +Codex Limits publishes cached state first and serializes availability probes, setup checks, process launches, network reads, file reads, and imports across Integrations. Disabled Integrations do no automatic source work; an explicit bounded Settings probe is the only exception. Local Activity collectors run only for a visible capability, repeated requests coalesce, superseded results cannot publish, and explicit refresh targets one Integration. + +Every source has wall-time or local-pass count bounds, payload, output, process-lifetime, and working-set bounds. Child processes are reaped after success, cancellation, timeout, hide, disable, sleep/wake recovery, and app termination. The Codex app-server may be shared inside a protocol burst but closes after five seconds idle, so the ten-minute account timer does not make its process resident. Claude is event-driven and has no recurring collection timer; Codex, Claude, and Grok each keep at most one demanded, cancellable one-shot task that advances display Freshness or reset state without source I/O. Unlimited on-disk allowance-history retention does not imply an unlimited in-memory history: ordinary refresh and periodic sync operate on bounded ranges or summaries. + +The OpenCode `serve --pure` collector is excluded from v1. A validation run of OpenCode 1.18.11 measured approximately 736 MiB RSS, about 66 MiB of isolated initialization writes, and a fixed port despite requesting port zero. Correct termination does not make that cost acceptable for passive analytics. OpenCode remains disabled and absent until a supported lighter source passes this ADR's budgets. + +Grok returns as an opt-in Beta after the 2026-09-10 protocol correction: ACP custom methods require an underscore on the wire. Official Grok Build 1.0.25 accepts `_x.ai/billing`; the earlier bare `x.ai/billing` probe did not prove the route absent. The collector uses a temporary empty working directory and a short-lived `grok agent --no-leader stdio` process, sends only initialization and billing requests, and leaves login and service communication to the CLI. It reads no credentials, calls no private backend directly, and parses no TUI. + +Grok collection runs every ten minutes only while its menu metric is selected, or when due for a visible or explicit demand. A monotonic thirty-second launch floor also applies to explicit actions. Automatic failures back off for 600, 1,200, 2,400, then at most 3,600 seconds; success restores the normal cadence. An unselected, hidden Grok integration performs no polling. Each fetch has one ten-second total deadline and a one-second graceful process-group termination bound, rejects total stdout above 1 MiB, discards stderr, and completes cleanup before another coordinated operation can start. Its latest allowlisted snapshot retains a 64 KiB limit. + +The authorized history extension adds separate provider-local Claude and Grok allowance journals retained until explicit Integration deletion. It adds no polling cadence. The active reader covers the latest 84 days through at most 85 direct UTC daily paths, bounded at 4 MiB per file, 32 MiB total, 512 bytes per record, and 200,000 decoded observations. Writes are locked and bounded, and an older latest-only snapshot seeds only its recorded observation. No token activity or provider session history is imported to reconstruct missing allowance points. Hidden detail histories are released while canonical files remain on disk. See the [measurement contract](../MEASUREMENT-CONTRACT.md) for estimate and deletion rules. + +The successful authenticated probe measured 77,578,240 bytes maximum transient child RSS and 0.208 seconds child CPU. These observations do not satisfy the idle or endurance gates. The all-enabled idle comparison, provider-owned startup-write measurement, and eight-hour soak remain pending; the earlier Codex-plus-Claude comparison does not cover Grok. See [current Grok validation](../research/grok-build-validation-2026-09-10.md). + +We accept temporarily different freshness, stale menu values, and provider-specific explicit actions instead of parallel polling, persistent hidden provider processes, eager history scans, or misleading live-refresh controls. Passive analytics must remain lighter than the coding work it observes, and release acceptance includes CPU, RSS, wakeup, process-count, and endurance budgets. diff --git a/docs/adr/0013-capability-driven-integration-surfaces.md b/docs/adr/0013-capability-driven-integration-surfaces.md new file mode 100644 index 0000000..a118aee --- /dev/null +++ b/docs/adr/0013-capability-driven-integration-surfaces.md @@ -0,0 +1,9 @@ +# Capability-driven Integration surfaces + +Codex Limits models Account Allowance, Account Facts, Local Activity, Guidance, and Analysis as independent Integration Capabilities instead of requiring provider parity. The user chooses Enabled Integrations and one fixed, explicitly named Menu Bar Metric. `All` and Integration detail contents remain curated per Integration in v1; individual capability and card switches are deferred. + +The `All` view uses cohesive but asymmetric rows with one primary fact, at most one secondary fact, and freshness only when it changes interpretation. Codex retains its deeper workspace and no combined allowance is calculated. Claude Code and Grok appear as opt-in Beta Integrations with their supported allowance facts. Grok shows the returned weekly or monthly usage period, reset, CLI provenance, and available plan/prepaid/PAYG facts; its menu metric is `Grok — Current-period usage remaining`. OpenCode retains its future Local Activity contract but remains absent until a supported lighter collector passes its gates. + +Claude Code and Grok detail surfaces also show Usage remaining burndown history from actual recorded allowance observations. Claude's seven-day and five-hour windows and Grok's weekly and monthly periods stay separate. Their bounded active view covers the latest 84 days, while canonical provider-local history remains until explicitly deleted. A previous latest-only cache seeds one observation, so initial charts may have one point. An estimate requires fresh, compatible observations over at least one minute and no gap over thirty minutes, reset, or correction. Codex's token-based analytics and cross-device history are not implied by these allowance charts. + +We accept a less uniform interface and less customization because forcing every Integration into the Codex data model would create empty UI, misleading estimates, unnecessary collection work, and settings complexity. We also accept an em dash or stale marker in the menu bar when the selected source cannot refresh passively instead of silently substituting another Integration. diff --git a/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md b/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md new file mode 100644 index 0000000..a031b8e --- /dev/null +++ b/docs/adr/0014-bounded-range-and-eventual-history-reconciliation.md @@ -0,0 +1,17 @@ +# Bounded range and eventual history reconciliation + +Status: Accepted and implemented on 2026-08-22 + +`UsageHistory` remains the single module and seam for canonical Codex allowance history. Callers request either the cached default view or one explicit date interval; they do not learn daily-file, manifest, writer, or cursor details. A returned view includes retained bounds, the covered interval, at most 6,000 ordered samples, and whether it is exact or downsampled. The default view covers the latest 84 days. An older request covers at most 84 days and runs off the main actor only after the visible graph's `Earlier` action. A cold default or range read considers at most 32 writer partitions, reads at most 256 daily files and 8 MiB, reports a partial result when a ceiling is reached, and releases the range when hidden. + +Each writer keeps a small atomic manifest containing format version, sync generation, oldest day, newest day, latest changed day, and a monotonic revision. It contains no usage values. Each local installation keeps atomic import and publication cursors keyed by sync lineage, generation, and writer. A periodic or explicit refresh prioritizes today and the manifest's latest changed day, then continues a calendar-day round robin from the persisted cursor. Across all writers it examines at most 32 calendar-day candidates and therefore reads at most 32 daily files. A candidate advances after a successful merge, a confirmed missing day, or a recorded malformed-file result; loss of access to the folder stops the pass without advancing unseen candidates. Relaunch resumes the same backlog; reaching the newest day wraps to the oldest, so an old changed or repaired file is eventually revisited without an unbounded scan or change journal. + +Only an explicit first connection may perform one complete reconciliation outside the main actor and initializes manifests and cursors. Later explicit refreshes use the same bounded pass as periodic refreshes. A pre-manifest writer uses a one-time filename-only manifest bootstrap; it does not decode historical payloads beyond the bounded candidates. Generation changes discard incompatible cursors before any import. Malformed files remain untouched and reported, valid later candidates continue, and a repaired file is picked up on a later round-robin visit; malformed data never causes valid canonical files to be deleted or skipped permanently. + +Current observations keep the existing direct write when the selected folder is available, so normal cross-device freshness remains tied to the current refresh cadence. The round robin is backlog recovery, not a claim that years of offline history become current in one pass. Settings may show `Backfilling history`; the main allowance and cached charts remain usable. Hiding an older range cancels its read and releases its samples. + +Acceptance uses ten-year and sparse-history fixtures to prove: no periodic or later explicit refresh examines more than 32 calendar-day candidates or reads more than 32 daily files; recent changes appear on the first due pass; every older offline day converges across repeated passes; cursor progress survives relaunch; generation/deletion prevents stale republish; malformed data remains untouched while later valid files converge and a repaired file is later merged; an older 84-day view returns at most 6,000 points; and ordinary default publication does not read range data. + +We reject newest-file truncation because it can omit offline history forever, a full scan on every refresh because work grows with retention, and a separate public history repository interface because `UsageHistory` already provides the correct deep module seam. + +Implementation validation uses a 3,650-day fixture while retaining every canonical daily file. The final 2026-08-22 Release run measured a 12.169 ms default load and a 0.007 ms disconnected automatic refresh. Focused tests prove the 32-candidate bound for both explicit and automatic refresh, relaunch-safe convergence over 100 offline days, newest-day priority, no rewrite or revision bump for unchanged data, continuation past a malformed day, later pickup after repair, cold-read writer/file/byte ceilings, visible-demand older-range loading, and bounded exact/downsampled older-range views. The complete Release suite passed 601 tests with zero failures in 26.632 seconds. diff --git a/docs/adr/0015-ship-claude-as-experimental.md b/docs/adr/0015-ship-claude-as-experimental.md new file mode 100644 index 0000000..f99aeb4 --- /dev/null +++ b/docs/adr/0015-ship-claude-as-experimental.md @@ -0,0 +1,21 @@ +# Ship Claude Code as experimental + +Date: 2026-09-10 + +Status: Accepted + +## Decision + +The product owner explicitly requested release 0.3.0 with Claude Code treated as experimental and waived the eligible Pro/Max account observation requirement. Claude Code is not installed on the release operator's Mac, so no live Claude allowance observation is claimed. + +Claude Code remains opt-in and is labelled `Experimental` in Settings and its detail header. Its setup consent, eligible-plan explanation, bounded relay, private storage, exact setup removal, and deterministic tests remain required. Grok retains its separate `Beta` label and live source evidence. + +The eligible-account check is recorded as `Waived`, not `Passed`, and no longer blocks the release validator. + +The product owner also explicitly waived the expanded all-enabled idle comparison and eight-hour mixed lifecycle soak for release 0.3.0 on 2026-09-10. Neither test was performed. Their PRD rows are `Waived for 0.3.0`; the validator accepts that exact release exception and rejects it for another version. CI, deterministic lifecycle tests, native QA, universal packaging, and update-signature validation remain required. + +## Consequence + +Release notes identify Claude Code as experimental and state that the real Pro/Max allowance path has not been verified. A later consenting tester can supply that evidence before the experimental label is removed. + +The release records that expanded idle overhead and eight-hour lifecycle stability are unverified. This acceptance does not claim either performance budget passed. diff --git a/docs/images/codex-limits-dashboard.png b/docs/images/codex-limits-dashboard.png deleted file mode 100644 index 12b86a7..0000000 Binary files a/docs/images/codex-limits-dashboard.png and /dev/null differ diff --git a/docs/prd/local-codex-analytics-workspace.md b/docs/prd/local-codex-analytics-workspace.md index c9942b3..de07b9b 100644 --- a/docs/prd/local-codex-analytics-workspace.md +++ b/docs/prd/local-codex-analytics-workspace.md @@ -170,7 +170,7 @@ The account-control boundary remains read-only. The app reads, calculates, shows - Keep reset notifications neutral and private. Include only the banked-reset fact and time to expiry; do not include Project names, account identifiers, token details, or Source Content. - Keep Derived Records without automatic expiry. Add an explicit destructive `Delete analytics history` action with confirmation. It removes all Codex Limits Derived Records on this Mac and every supported account usage record in the selected sync folder, including files from other installations. - Deletion advances an empty sync generation so another Mac cannot republish older history. If the sync folder is unavailable, keep deletion pending, block older imports, and do not claim completion. Do not rebuild deleted history automatically; a separate explicit rebuild action may read only sources that still exist. -- Continue using versioned, account-partitioned local records and atomic writes so the app remains lightweight. Remove the current 90-day cutoff. Migrate existing usage history without losing valid samples. +- Continue using versioned, account-partitioned local records and atomic writes so the app remains lightweight. Remove the current 90-day cutoff. Migrate existing usage history without losing valid samples. Unlimited on-disk retention must use a Bounded Working Set: ordinary refresh, sync, reader snapshots, and charts may load only the requested range or bounded summaries, never the entire retained history. - Keep the existing user-selected folder limited to account usage samples. Do not copy Task Tree, agent, model, Source Content-derived, or Codex-assisted records into that folder in this PRD. - Do not store copied prompts, responses, code, paths, commands, or tool output in Analytics History. Persist only compact facts, aggregates, classifications, fingerprints, source state, Coverage, and Confidence. - Do not send Codex-derived product telemetry. The only external data path added by this PRD is the user-requested `Analyze with Codex` request described above. @@ -178,7 +178,7 @@ The account-control boundary remains read-only. The app reads, calculates, shows - Keep all visible UI free of debug text, internal reasoning, test notes, implementation notes, and unsupported claims. - Preserve graceful degradation. If one source fails, keep valid data from other sources, mark missing Coverage, and withhold only the affected conclusions. - Keep refresh work off the main actor except for publishing the final reader snapshot. Batch disk reads and UI updates to avoid churn while Codex is active. -- Preserve the menu-bar quick percentage, wake refresh, panel-open refresh, manual refresh, and periodic refresh. Persistent notifications may add lower-latency updates but do not remove full reconciliation reads. +- Preserve the menu-bar quick percentage, wake refresh, panel-open refresh, manual refresh, and periodic refresh. Persistent notifications may add lower-latency updates but do not remove bounded reconciliation reads. ## Testing Decisions @@ -210,7 +210,7 @@ The account-control boundary remains read-only. The app reads, calculates, shows - Test source isolation: failure of account reads must not erase Local Activity; failure of local parsing must not erase account facts; a malformed history file must not replace valid history. - Add view-level tests for a persistent current-state header; `Graphs`, `Facts`, and `Insights` navigation; screen-aware height and small-display reflow; persistent but source-scoped filters; empty and stale states; complete and partial reset copy; destructive confirmation; and visible provenance. - Add accessibility checks for keyboard focus order, button labels, chart summaries, selected-point text, contrast, Dynamic Type behavior where macOS supports it, and reduced-motion behavior. -- Add performance fixtures representing years of compact history and thousands of Tasks. Verify bounded incremental reads, no full-history scan on ordinary refresh, and responsive snapshot publication. +- Add performance fixtures representing 90 days and ten years of compact history plus thousands of Tasks. Verify bounded incremental reads, no full-history scan on ordinary refresh or periodic sync, bounded reader snapshots, responsive publication, and no proportional increase in steady-state RSS or ordinary refresh time. - Run the existing Swift test suite throughout migration. Existing forecast, client, and history behaviors remain regression requirements unless this PRD explicitly replaces them. ## Out of Scope diff --git a/docs/prd/multi-integration-workspace.md b/docs/prd/multi-integration-workspace.md new file mode 100644 index 0000000..cfd388f --- /dev/null +++ b/docs/prd/multi-integration-workspace.md @@ -0,0 +1,373 @@ +# Multi-integration workspace + +Status: Accepted for v1 implementation + +Release 0.3.0 is accepted with the explicit owner exceptions recorded in [ADR-0015](../adr/0015-ship-claude-as-experimental.md). Unperformed checks remain marked as waived rather than passed. + +## Destination + +The multi-integration model covers Codex, Claude Code, Grok, and OpenCode without pretending that they expose equivalent data. The v1 development workspace presents Codex, Claude Code, and Grok. Claude Code is opt-in `Experimental` and Grok is opt-in `Beta`; release acceptance remains subject to the gates below. OpenCode remains a future Integration until a supported lighter source passes the same functional, privacy, and performance gates. Users choose which shipped Integrations participate and which single Integration metric appears in the menu bar. Disabled Integrations perform no automatic source collection. Enabled Integrations collect only what their visible or explicitly selected capabilities require. + +The product remains passive analytics. A fresher value is never worth noticeable CPU, memory, disk, network, process, or UI cost. + +The 2026-08-22 spike conditionally accepted Claude Code and rejected the OpenCode local-server collector after measuring approximately 736 MiB RSS and 66 MiB of initialization writes. Its Grok exclusion was based on the bare method `x.ai/billing`, which is not the ACP wire name. On 2026-09-10, official Grok Build 1.0.25 returned an authentication-required response for `_x.ai/billing` in an isolated environment without a login and valid billing data with CLI-managed authentication. Grok is restored to the development scope through that read-only route. The historical 1.0.5 binary has not been retested with the corrected method, so no claim is made about its support. + +Codex Limits reads no Grok credentials or browser sessions, calls no private billing backend directly, and parses no TUI. Claude’s implementation checks and historical Codex-plus-Claude idle comparison remain evidence for their original scope. Provider-owned Grok startup writes are measured. The product owner waived the eligible Pro/Max Claude observation for this experimental release, plus the expanded all-enabled idle comparison and eight-hour lifecycle soak for 0.3.0; see [ADR-0015](../adr/0015-ship-claude-as-experimental.md). These exceptions provide no evidence that the unperformed checks passed. See [current Grok validation](../research/grok-build-validation-2026-09-10.md) and the [historical validation spike](../research/multi-integration-v1-validation-spikes-2026-08-22.md). + +## Confirmed decisions + +- Codex keeps its deeper Graphs, Facts, and Insights where Codex-specific evidence supports them. +- Claude Code and Grok have Usage remaining burndown charts and estimates based on their own recorded allowance observations. Claude's seven-day and five-hour windows and Grok's weekly and monthly periods remain distinct; no past allowance is manufactured. +- Claude Code, Grok, and OpenCode show only capabilities supported by their own sources; the UI does not manufacture provider parity. +- Grok is compiled into the development build as an opt-in Beta using the official CLI’s read-only ACP billing extension. OpenCode is absent because its validated local-server source violates the performance contract. +- Users enable and disable each Integration independently in Settings. +- New installations enable Codex only and select `Codex — Weekly usage remaining` as the menu bar metric. Existing installations migrate to the same selection so current behavior is preserved. +- Codex can be disabled. Codex Limits remains usable with any subset of Integrations, including none. +- Integration enablement, executable selection, setup, readiness, and menu bar selection are device-local settings. They are not synchronized between Macs. +- v1 has one switch per Integration. Overview and detail contents are fixed per Integration; v1 does not expose capability-level visibility switches. +- The user selects exactly one Enabled Integration metric for the menu bar, or `None`. The menu bar never combines metrics. +- Unsupported capabilities are omitted instead of displayed as zero or as a temporary source failure. +- A disabled Integration performs no automatic polling, process launch, file scan, import, or network request. A bounded availability check explicitly initiated from Settings is the only exception. +- Disabling preserves app-owned snapshots, recorded allowance history, and user preferences. Active source hooks must be deactivated when Codex Limits owns their exact configuration. Data removal is a separate explicit action. +- UI work follows the `emil-design-eng` principles: native controls, purposeful copy, immediate feedback, stable layout, restrained motion, reduced-motion support, and animation limited to `transform` and `opacity` when animation is justified. +- Ordinary refresh must not make the Mac feel busy. Every collection job is bounded, cancellable, serialized, and measured. + +## User control over displayed data + +Settings separates two choices: + +1. **Enabled Integrations** decide which products appear in `All` and workspace navigation. +2. **Menu bar metric** selects one fixed, named metric from an Enabled Integration or `None`. + +The menu bar picker contains only currently Enabled Integrations supported by this build. The OpenCode row below is a deferred contract and remains absent: + +| Picker value | Displayed metric | +|---|---| +| `None` | App icon without a numeric value | +| `Codex — Weekly usage remaining` | Weekly Codex Usage remaining | +| `Claude Code — 7-day usage remaining` | Last observed seven-day Usage remaining | +| `Grok — Current-period usage remaining` | Weekly or monthly Usage remaining returned by Grok Build | +| `OpenCode — 7-day local tokens` | Rolling seven-day Local Token Activity using compact `K` or `M` notation | + +The contents of `All` and Integration detail views are intentionally fixed in v1. They show the strongest supported facts defined below; users do not configure individual cards or fields. + +## Settings and Integration lifecycle + +Settings uses one native row per Integration. Each row contains the Integration name, `Experimental` for Claude Code or `Beta` for Grok, an actionable readiness state when needed, and its switch. A healthy Integration does not add decorative status copy outside Settings. + +Opening Settings renders cached readiness immediately. Once per Settings presentation, executable existence checks may run sequentially as cheap file metadata reads. Settings does not launch every CLI. Version, authentication, server, or capability probes run only after the user enables that Integration or selects its explicit `Set up`, `Check again`, or `Locate…` action. These probes use the same serialized work coordinator as collection and are cancelled when no longer needed. + +Codex Limits checks known native installer and Homebrew locations without starting a login shell. If an executable is elsewhere, `Locate…` lets the user select it explicitly. The selected executable must be a regular executable file. Detection never reads credentials and never enables an Integration. + +| Enabled | Readiness | Reader behavior | Available action | +|---|---|---|---| +| No | Cached status only | Omitted from `All` and navigation | Enable | +| Yes | `Checking` | Cached snapshot if one exists; otherwise a stable placeholder | Cancel by disabling | +| Yes | `Not found` | Integration destination shows one setup message | `Locate…` or install instructions | +| Yes | `Set up` | Integration destination shows one setup message | Provider-specific `Set up` | +| Yes | `Waiting for data` | No numeric zero; explain how the first observation appears | Provider-specific guidance | +| Yes | `Ready` | Show cached facts immediately | Optional provider-specific refresh | +| Yes | `Update required` | Keep the last compatible snapshot stale | Update instructions | +| Yes | Specific error | Preserve the last valid snapshot when still meaningful | `Check again` or setup repair | + +State transitions follow these rules: + +- Enabling makes the Integration visible immediately, even when setup or data is pending, so the action has an observable result. +- Disabling cancels queued and active jobs, deactivates exact app-owned source hooks, removes the Integration from `All` and navigation, and preserves snapshots and preferences. +- Disabling the current menu bar source selects `None`; Codex Limits never silently selects another source. +- Disabling the currently visible Integration returns the workspace to `All`. +- With no Enabled Integrations, the menu bar shows only the app icon and the workspace shows `No integrations enabled` with `Open Settings`. +- Re-enabling may reuse a compatible cached snapshot but must evaluate its current Freshness before display. + +## Workspace navigation + +Navigation is `All` followed by every Enabled, shipped Integration in stable product order. v1 therefore offers `Codex`, `Claude Code`, and `Grok`; the future order continues with `OpenCode`. Disabled or deferred Integrations are absent. Integration details share a simple header, remaining allowance, reset, and primary chart. Codex keeps pace and runway under `Usage details`; its `More` menu retains the supported graphs, Facts and reset reminders, Insights, and available updates. + +`All` is a compact Integration Overview. It uses one cohesive row style without identical placeholders. Each ready row has a primary allowance, compact reset information, and a small current-window chart on the right. Claude also includes its five-hour remainder. Freshness stays beside the facts when it changes interpretation. Rows open the corresponding detail. Thumbnails show only actual observations, their latest point, and the target; they omit estimates, axes, legends, and point controls. Setup and error rows replace facts with one short recovery action instead of adding banners or global loading UI. + +The OpenCode row describes its future capability contract and is not rendered in v1. + +| Integration | Overview primary | Overview secondary | Detail contract | Explicitly omitted | +|---|---|---|---|---| +| Codex | Weekly Usage remaining | Reset | Existing guidance, Graphs, Facts, and Insights | Nothing already accepted by the Codex analytics PRD | +| Claude Code | Seven-day Usage remaining | Reset and five-hour Usage remaining | Recorded seven-day and five-hour burndown history, supported allowance estimates, both resets, Last observed, and source state | Session model, session cost, local telemetry, and token-derived guidance | +| Grok | Current weekly or monthly Usage remaining | Reset | Recorded period-specific burndown history, supported allowance estimates, period, plan, available prepaid/PAYG facts, shared-pool provenance when returned, and CLI version | Grok Build local sessions and token-derived guidance | +| OpenCode | Rolling seven-day Local Token Activity | Root session count | Token categories, estimated local cost, projects, sessions, current saved provider/model, and parent/child relationships | Account allowance, provider billing claims, exact per-response model attribution; all shipped v1 surfaces | + +Background updates never replace the workspace with a spinner. Cached content remains in place. Manual refresh may show a small inline progress indicator beside the initiating action. Frequent data updates do not animate or shift layout. + +## Menu bar behavior + +- A fresh selected metric shows its compact value. +- A stale selected metric may retain the last valid value with a non-color-only stale indicator. +- An expired allowance or a source with no valid snapshot shows an em dash, not the previous percentage or zero. +- OpenCode uses a distinct token icon and never displays a percent sign. +- VoiceOver announces the Integration, value to at most two decimal places, unit, and freshness; visual integer or `K`/`M` compaction is presentation only. +- Changing the menu bar metric publishes a compatible cached value immediately and enqueues work only when that source is due. + +## Performance contract + +### Scheduling + +- Every reader surface publishes cached state before scheduling source work. +- Opening `All` may enqueue due allowance reads after rendering, one at a time. It never starts parallel fan-out or Local Activity import. +- On launch, the app refreshes only the selected menu bar source when due. Other Integrations wait for `All`, their detail view, or an explicit action. +- The work coordinator admits at most one active operation across availability probes, setup probes, process launches, file reads, imports, and network reads. An idle transport performs no collection but its process still counts toward process and memory budgets. +- Work priority is: explicit user action, a visible `All` or detail capability, an automatic selected-menu source, then a Settings availability check. +- Requests for the same Integration and capability coalesce. A newer generation supersedes queued work and prevents a late result from publishing. +- An explicit refresh may cancel lower-priority cancellable work. It does not start concurrently with that work. +- An in-flight request for the same Integration and capability coalesces. Provider `Retry-After` and compatibility cooldowns become mandatory if a future shipped source exposes them. Every Grok launch has a monotonic minimum interval of 30 seconds, including explicit reads. A future OpenCode collector must enforce the same floor. +- Hiding a detail view cancels work needed only by that view and releases its bounded in-memory detail cache. +- Disabling an Integration cancels its work before publishing the disabled state. + +The implemented Codex account timer is armed only while Codex is Enabled and its weekly allowance is the selected menu bar source. Launch and wake perform a due check only under that same condition. Selecting another menu source or `None` cancels the timer; opening `All` or the Codex detail performs a serialized due read without re-arming background work. + +### Source policy + +| Integration capability | Automatic policy | Explicit action | +|---|---|---| +| Codex Account Allowance | Every 10 minutes only while selected for the menu bar; due reads from visible `All` or Codex detail | Fetch current account state | +| Claude Code Account Allowance | Event-driven `statusLine`; no app polling, recurring collection timer, or dummy prompt | Re-read relay cache; explain that new data appears during Claude Code activity | +| Grok Account Allowance | Every 10 minutes only while selected for the menu bar; due reads for enablement, visible `All`, or Grok detail; automatic failure backoff | Read current billing through the official CLI, respecting the 30-second launch floor | +| OpenCode Local Activity | Deferred; no v1 collector, polling, or process launch | Unavailable in v1 | +| Codex Local Activity | Existing visibility-gated incremental collector | Existing bounded refresh | + +Claude schedules no recurring collection timer. When Claude is Enabled but neither selected for the menu bar nor visible in `All` or its detail, app launch and relay notifications do not read its settings or cache. Settings, enablement, menu selection, or visible `All`/detail creates bounded demand; hiding the workspace removes visible demand. While a valid Claude snapshot has menu or visibility demand, the app keeps at most one cancellable one-shot display task for the next Freshness or reset boundary; that task performs no source I/O and is replaced rather than accumulated. Codex uses the same one-shot display-only rule while its menu metric or workspace surface is visible, so a stale or expired value changes without an extra source read. Grok uses the same display-only boundary rule. Its collection timer exists only while the Grok menu metric is selected; when hidden and unselected it performs no automatic source work. OpenCode performs no work in v1. + +Codex keeps its fixed ten-minute selected-menu cadence after an automatic failure; it never retries more frequently because of the failure. Claude is event-driven and has no automatic retry loop. Grok failures delay automatic reads by 600, 1,200, 2,400, then at most 3,600 seconds. Success resets that backoff. Explicit reads may bypass automatic backoff but never the monotonic 30-second launch floor. A future polling collector must define and test its own backoff before shipping. + +### Operation and process bounds + +- Every provider protocol operation has a 10-second wall deadline unless a smaller provider-specific deadline applies. Bounded local file passes use count and byte limits; the user-confirmed initial history connection remains the documented complete-reconciliation exception. +- Deadline or cancellation terminates the complete app-owned process group: request graceful exit, wait at most one second, then force termination and reap every child. +- The Codex app-server connection may be reused inside one short burst, then closes after five seconds without protocol work. The ten-minute account timer must not keep that child resident between refreshes. +- Grok starts an owned process group in a temporary empty working directory. Its entire initialization-plus-billing fetch has a ten-second deadline, followed by at most one second for graceful group termination before forced cleanup. Cancellation finishes cleanup before the shared coordinator admits another operation. No Grok child stays resident between reads. OpenCode starts no child. +- Grok rejects more than 1 MiB total stdout per fetch, discards stderr, and never persists raw output. Codex JSONL responses are streamed with an 8 MiB line cap. A future captured provider stream must also remain bounded. +- A decoded HTTP or RPC response is rejected above 8 MiB. Provider-specific lower bounds remain preferred. +- Codex Local Activity keeps its per-pass maximum of 10,000 lines or 8 MiB and continues incrementally outside the main actor. +- A future OpenCode source may publish at most the 500 newest sessions in the rolling seven-day range. Fetching all sessions and truncating locally is not acceptable. +- A future OpenCode source never requests messages or parts. The validated `--pure` local-server method is rejected for v1 because it failed memory, initialization-write, and random-port requirements. +- Grok Build local history is outside v1 and performs no local scan. +- Claude relay input is capped at 256 KiB. The relay validates and writes only its bounded allowlist using an atomic, ordered update; raw stdin is never stored or logged. +- Source reads, process management, decoding, aggregation, and persistence run outside the main actor. The main actor only publishes a bounded immutable reader snapshot. + +### Bounded history and memory + +Unlimited allowance-history retention applies to compact on-disk canonical stores, not to resident memory. Before multi-integration implementation is accepted: + +- `UsageMonitor` and reader snapshots must stop retaining every historical sample; +- ordinary refresh and periodic sync must not enumerate or decode the complete history; +- range views request only their bounded interval and resolution; +- long-term charts use bounded summaries or downsampled points; +- Local Activity detail caches are released when their capability becomes hidden; +- adding years of history must not proportionally increase steady-state RSS or ordinary refresh CPU time. + +The implemented default Codex reader path keeps at most 6,000 samples from the latest 84 days in memory. It preserves full resolution for the latest eight days and keeps the first and last observation per older hourly/reset bucket plus explicit comparison breaks. Canonical files remain subject to the existing unlimited-retention and deletion contract. + +Claude Code and Grok retain separate compact UTC daily observation journals until explicit deletion. Their active reader covers the exact latest 84 days by at most 85 direct daily paths, with ceilings of 4 MiB per file, 32 MiB total, 512 bytes per record, and 200,000 decoded records. The reader does not downsample or enumerate older history; malformed committed records and exceeded bounds produce an explicit history failure. Each journal append writes at most 64 records, uses a cross-process lock, and limits tail recovery and deduplication to about 33 KiB. App-owned history directories use `0700` permissions and files use `0600`. Prior latest-only caches seed their actual observation time and available windows only. Retention does not imply synthetic earlier points or cross-device sync. + +The Codex default working set matches the longest preset (`12 weeks`). Its implemented explicit range reader can separately load any requested interval up to 84 days, returns retained and covered bounds, caps the result at 6,000 samples, marks it `exact` or `downsampled`, checks cancellation between files, and leaves canonical data outside the resident window reachable. A cold default or explicit-range read considers at most 32 writer partitions, reads at most 256 daily files and 8 MiB, and reports partial history if any ceiling is reached. Older-range source work starts only from the visible Codex graph's `Earlier` action and is cancelled and released when hidden. + +The implemented bounded reconciliation in ADR-0014 replaces complete work on periodic and explicit refresh. It examines at most 32 calendar-day candidates and reads at most 32 daily files across all writers, prioritizes recent changes, persists round-robin backlog progress across relaunch, avoids rewriting unchanged days, and preserves generation/deletion behavior. Only a user-confirmed initial folder connection may perform one complete reconciliation outside the main actor. + +### Provisional release budgets + +Budgets are measured in a Release build on supported Apple Silicon hardware after a ten-minute warm-up. The provider and performance spikes may tighten these values; relaxing them requires an explicit ADR. + +| Measure | v1 gate | +|---|---| +| Cached workspace publication | p95 below 100 ms | +| Main-thread refresh work | No uninterrupted work longer than one display frame | +| New-integration idle RSS, all enabled with Codex selected and detail hidden | No more than 10 MiB above the Codex-only baseline | +| Idle CPU regression in the same state | Less than 0.2 percentage points averaged over 30 minutes | +| New-provider child processes in the same state | Zero | +| Recurring new-provider collection wakeups in the same state | Zero | +| Display-only boundary tasks | At most one per demanded shipped Integration with a valid snapshot; zero while disabled or hidden and unselected | +| Eight-hour mixed lifecycle soak | RSS no more than 5 MiB above its post-warm-up value; process count returns to baseline after every operation | +| Ten-year compact Codex fixture versus 90-day fixture — peak RSS | Difference no greater than 10% or 10 MiB, whichever allowance is larger | +| Ten-year compact Codex fixture versus 90-day fixture — ordinary refresh | Difference no greater than 10% or 10 ms, whichever allowance is larger | +| Automatic collection CPU time | p95 below one second for the app and its child process combined on accepted fixtures | +| Disabled Integration | Zero automatic source operations and zero app-owned live child processes | + +The canonical idle comparison uses `Scripts/measure-app-idle.sh PID 2400 10 OUTPUT.csv` once per state. The first 60 samples are the ten-minute warm-up and the final 180 samples are the 30-minute measurement window. The raw CSV records parent and direct-child RSS, CPU, and child count; a shorter run is diagnostic only and cannot satisfy this gate. + +The soak covers repeated workspace open/close, Integration switching, explicit refresh, enable/disable, network failure, malformed data, CLI timeout, and sleep/wake. No collection, cache, timer, retained task, file descriptor, or child-process count may grow with repetition. + +### Canonical eight-hour lifecycle soak + +The soak uses a signed Release QA build with its isolated bundle identifier, defaults suite, Analytics History, Claude settings fixture, and Integration data directory. It must not read or modify the production app's preferences or the user's real Claude settings. Enable Codex and every Integration eligible to ship in the tested build, select the Codex weekly menu metric, leave Settings closed between actions, and begin with no provider child process. + +Run `Scripts/measure-app-idle.sh PID 29400 60 OUTPUT.csv`. Samples 1–10 are the ten-minute warm-up; samples 11–490 are the eight-hour measurement. At the end of warm-up and after each hourly cycle, separately record the app's open-file count and the QA data directory's file count and byte size. These checkpoint reads run only nine times and are not a recurring app workload. + +Perform one settled lifecycle cycle in each measured hour: + +1. open and close the workspace three times, then visit `All`, `Codex`, `Claude Code`, `Grok`, and `All`; +2. request one explicit Codex refresh and wait for its bounded app-server burst to finish; +3. disable, re-enable, and set up Claude Code using only the QA fixture paths, then deliver one accepted bounded relay fixture and one malformed fixture; +4. exercise Grok selection, refresh, disable/re-enable, failure recovery, and malformed/timeout responses through a bounded test CLI, respecting its thirty-second launch floor; keep fixture provider state separate from the user’s Grok installation; +5. wait 20 seconds — the ten-second source deadline, five-second Codex idle release, and five-second observation margin — before taking the checkpoint. + +One hourly cycle must span a user-confirmed real network-unavailable/recovery event, and another must span a user-confirmed real sleep/wake event. Synthetic notifications, virtual clocks, or disconnecting the user's network without confirmation cannot satisfy those two checks. The Grok CLI-timeout cycle is now required. Deferred OpenCode is not included or simulated through another Integration. Real Grok account-source measurements complement the fixture soak and must not be replaced by fixture results. + +The soak passes only when the mean parent RSS of samples 481–490 is no more than 5,120 KiB above the mean of samples 11–20, every child-process count returns to its post-warm-up baseline within the 20-second settling boundary, and the settled file-descriptor, app-owned file, and cache counts do not grow across the eight cycles. Swift timer and task inventories are not observable through `ps` or `lsof`; their bounded/cancelled state requires the deterministic lifecycle tests and code invariant in addition to the soak's observable no-wakeup and no-growth evidence. Any crash, orphan process, missed cancellation, unbounded file/cache growth, or required use of production provider settings fails the gate. + +## Freshness and allowance lifetime + +Freshness describes observation age and source availability. Allowance lifetime additionally respects the known reset boundary. + +The OpenCode row below is a future contract. Freshness windows are separate from collection schedules. + +| Integration | Fresh window | +|---|---:| +| Codex | 15 minutes | +| Claude Code | 30 minutes | +| Grok | 30 minutes | +| OpenCode | 15 minutes | + +| State | Behavior | +|---|---| +| `Fresh` | Show the latest valid value normally | +| `Stale` | The observation exceeded its Freshness window but remains inside the same known allowance period; retain it with age and stale indication | +| `Expired` | A known allowance reset has passed without a post-reset observation; hide the old percentage and show that a new observation is needed | +| `Unavailable` | No valid compatible observation exists; show a specific setup or source reason, never zero | + +Claude always labels its allowance `Last observed`. A failed refresh never erases a still-meaningful snapshot. No provider may carry a numeric allowance value across its known reset boundary. + +## Provider measurement gates + +### Claude Code + +- Map each independently present `used_percentage` to `remaining = 100 - used` only after finite-range validation. +- Missing five-hour or seven-day data does not create zero. +- Multiple relay processes serialize atomic writes. The event with the newest receive time wins even if an older process finishes later. +- v1 retains allowance observations on this Mac and shows seven-day and five-hour burndown history separately. Neither stable account identity nor continuity across an unobserved account change is claimed. +- Enabling explains that a configured custom status line changes Claude Code's footer and runs a local command during Claude activity. Setup requires confirmation. +- Settings and the setup confirmation state that allowance data requires an eligible Pro or Max account. A Free account waiting without a snapshot is an expected unsupported-plan state, not a zero allowance or a collector failure. +- Setup reads and writes only the user settings file. An existing user `statusLine` is never overwritten or automatically wrapped. Codex Limits does not scan project, local-project, or managed settings; those higher-precedence scopes remain untouched, may override the user status line, and are named in the setup confirmation. Manual composition remains user-owned. + +The packaged implementation uses a 256 KiB input cap, a 64 KiB cache cap, an atomic `0600` allowlisted snapshot, cross-process ordering, and a 30-second equivalent-write floor. An eligible allowance produces the compact usage footer; an event with no allowance produces the neutral `Usage unavailable` footer without writing a snapshot. Setup, exact removal, changed-configuration preservation, app-owned data deletion, executable selection, and suppression of superseded lifecycle results have deterministic tests. Codex and Claude source work share one priority-aware coordinator, so background collection cannot overlap an Integration action. + +### Grok + +- Use the official user-managed CLI: `grok agent --no-leader stdio`, then sequential `initialize` and `_x.ai/billing` requests. The underscore is required on the ACP wire. Create no coding session, send no prompt, and invoke no authentication flow. CLI-managed login remains outside Codex Limits. +- Prefer `creditUsagePercent` and `currentPeriod`; legacy `monthlyLimit`, `used`, and `billingPeriodEnd` apply only when both current fields are absent. Present-but-invalid current data does not fall back to legacy values. +- Preserve the finite original used percentage as `reportedUsedPercent`, clamp only for display, and retain `measurementSource` (`creditUsagePercent` or `legacyCredits`). Missing allowance and zero legacy limit are unavailable; a present empty Cent object is a valid zero monetary value. +- Accept exact weekly and monthly source period types. Validate supported finite reset timestamps, including fractional seconds and UTC offsets. Retain optional provider-reported `currentPeriod.start` or legacy `billingPeriodStart` only when it is supported, finite, and earlier than reset; a missing start remains absent. Never infer a monthly start from a fixed duration. A valid past reset remains an expired cached observation. Unknown periods, invalid resets, missing configuration, authentication failure, unsupported method, timeout, and incompatible schema have safe distinct errors. +- Prepaid, on-demand, subscription-tier, and unified-pool fields are optional Account Facts. Missing fields remain absent and never replace the current-period allowance. A shared-pool percentage is not attributed solely to Grok Build. +- Obtain source version from initialization metadata (`_meta.agentVersion`), without a separate version process on every read. Bound and sanitize optional display strings. +- Official stable 1.0.25 passed a real authenticated read on 2026-09-10. The probe used 77,578,240 bytes maximum transient child RSS, 0.208 seconds child CPU, 4,349 stdout bytes, and no stderr; wall time including cleanup was 1.984 seconds. No model request was made, and the owned process group stopped with SIGTERM. A second live probe sampled 897,024 physical disk-write bytes and 1,552,384 logical-write bytes through the billing response, with no retained files in its temporary working directory; see the validation note for scope. +- The complete Grok/Claude history and chart implementation passed the 644-test Release suite on 2026-09-10 with zero failures. This includes source/date validation, bounded journal reads and locks, lifecycle and deletion races, actual-only history, and compatible-observation forecasts. Signed native QA verified both providers’ charts, point selection, zoom, and separate range state using synthetic observations. All-enabled performance/lifecycle gates remain separate checks; the eligible live Claude observation is waived for the experimental release. + +See [Grok validation and local test steps](../research/grok-build-validation-2026-09-10.md). +### OpenCode + +This section is a deferred compatibility contract, not v1 implementation scope. + +- Rolling seven days is the exact 604,800 seconds ending now. +- Local Token Activity is the sum of finite non-negative `input`, `output`, `reasoning`, `cache.read`, and `cache.write` counters after the spike confirms they are disjoint cumulative categories. +- Root session count excludes child sessions; detail may show the complete bounded parent/child set. +- Parent and child totals are not added until the spike proves they are not already inclusive. +- A missing or invalid cost is unavailable, not zero. Visible cost is `OpenCode local estimated cost` and never billing. +- Session provider/model is labeled as the currently saved session selection. Exact multi-model attribution is unavailable without messages and remains outside v1. +- The tested `1.18.11` server proved a count bound and complete process cleanup but failed the performance and source-mutation gates. Production stays deferred until another supported interface also proves counter behavior after compaction/fork/archive/delete, source equivalence, bounded writes, and the release budgets. + +## Retention and identity + +- Codex keeps its accepted on-disk Analytics History, account partitions, sync, forecasts, and deletion semantics while adopting the bounded-memory requirements above. +- Claude Code and Grok retain their latest valid Integration Snapshot plus compact allowance observations until `Delete integration data`. Each latest snapshot has a 64 KiB bound. Their bounded detail history views cover the latest 84 days without deleting older records. `All` reads only the current period, capped at 31 elapsed days / 32 UTC files, and retains at most 256 display points per thumbnail; raw history is released after conversion. Hidden views and menu-only demand do not load thumbnail history. Source polling cadence is unchanged. +- Every chart point is a recorded observation. A previous latest-only cache seeds its one observation and supported windows; unavailable earlier history remains unavailable. Repeated cache reads never invent new observation times. +- Claude Code, Grok, and any future OpenCode integration use separate Local Installation Partitions. They do not sync between Macs or support cross-device comparisons. Stable provider account identity is unavailable for Claude and Grok, so recorded history does not establish account continuity through an unobserved login change. +- Claude and Grok estimates need at least two compatible observations spanning at least 60 seconds, a latest reading within 30 minutes and before reset, and no intervening gap over 30 minutes, reset, source change, or correction. Unsupported estimates remain withheld. Allowance charts never substitute token, cost, or session activity for allowance readings. +- A future OpenCode integration retains only a bounded rolling seven-day cache of at most 500 sessions. + +## Privacy, diagnostics, and deletion + +The OpenCode row is a future allowlist and does not authorize v1 collection. + +| Integration | App-owned data allowed | +|---|---| +| Claude Code | Latest allowance windows and recorded observations, fixed window durations and starts, resets, receive time, and CLI version | +| Grok | Latest original finite used percentage and recorded remaining observations, period, provider-reported start when present, reset, measurement source, optional plan/prepaid/PAYG/unified-pool facts, observation time, and CLI version | +| OpenCode | Session identifiers and times, aggregate tokens and estimated cost, current saved provider/model, parent relation, keyed project identity, and short project name | + +Codex Limits does not persist Claude model/session/cost, prompts, responses, OpenCode messages or parts, tool output, credentials, auth files, raw provider responses, child-process output, or full project paths for these Integrations. The same allowlist applies to logs and crash diagnostics. Diagnostics retain only the latest bounded redacted reason per Integration. + +`Delete integration data` is available for Claude Code and Grok; OpenCode remains deferred. It first disables the Integration and cancels its work, then deletes its app-owned snapshot, retained allowance history, Derived Records, cache, executable preference, and exact Codex Limits-owned source configuration. It never deletes records owned by the integrated product. Grok deletion removes its app-owned snapshot, history, and executable preference after disable/cancellation; it preserves Grok Build’s files, settings, and login. Codex continues to use the separate `Delete analytics history` contract. + +Disabling Codex pauses its account timer, local collection, history exchange, and new Assisted Insights work; it cancels any scheduled Reset Reminder. It preserves Codex history, sync preference, snapshots, and analytics settings for re-enable. + +## Source setup + +- **Claude Code:** setup may install the Codex Limits relay in user settings only when that file has no `statusLine` and after showing the footer consequence, possible higher-scope override, and eligible-plan requirement. The app-created relay outputs a useful minimal status line rather than a blank row. Disable or deletion removes the active config only when it still exactly matches what Codex Limits created; if setup created an otherwise empty settings file, exact removal deletes that file. Project, local-project, and managed settings are never scanned or modified. For manual user-owned composition, disabling stops app data writes through the enabled marker but cannot prevent Claude from invoking the user's command; Settings must explain how to remove that composition completely. +- **Grok:** enable its Beta Integration in Settings. Detect `~/.grok/bin/grok` and known Homebrew paths through file metadata, or select a regular executable with `Locate…`. Billing reads use the official CLI’s `_x.ai/billing` route. Missing login directs the user to `grok login`; the app starts no login flow, reads no auth files or cookies, and never calls the private billing backend directly. Disable cancels collection and preserves the cache; deletion removes only app-owned Grok data and its executable preference. +- **OpenCode:** no source setup ships in v1. The rejected local-server method, ambient TUI servers, auth-file reads, and direct internal-database reads are not fallbacks. A future source must be official, read-only, bounded, and independently measured. + +## UX and accessibility acceptance + +- Every switch and action is reachable with keyboard navigation and has a VoiceOver label and current value. +- Fresh, stale, expired, waiting, and error states are not distinguished by color alone. +- Cached rows keep stable dimensions while refreshing; no skeleton replaces valid data. +- Keyboard-initiated navigation and frequent data updates do not animate. +- Optional motion respects Reduce Motion and never delays interaction. +- Compact menu values expose full values and units to accessibility APIs. +- Visible UI contains no implementation notes, fixture names, debug output, or internal provider errors. + +## Testing decisions + +- Test new install, existing-install migration, all-disabled state, device-local preferences, menu source selection, selected-source disable, visible-source disable, and re-enable with stale cache. +- Test every readiness transition and recovery action without global loading UI. +- Test Fresh, Stale, Expired, and Unavailable around exact reset boundaries and clock/time-zone changes. +- Test in-flight request coalescing, priority, cancellation, generation guards, deadlines, rapid clicks, display-only boundaries, and late results. Grok tests additionally cover selected-menu cadence, automatic failure backoff and reset, the monotonic launch floor, and hidden/unselected suppression. +- Test zero automatic source operations for disabled Integrations, including after launch, wake, Settings close, and app relaunch. +- Test Claude missing windows, invalid percentages, concurrent relays, out-of-order completion, exact install/uninstall including a previously absent user settings file, data deletion, user-settings conflicts, and user-owned composition. Keep higher-scope settings outside the app's read boundary and test their override explanation as UI copy. +- Complete one eligible-account observation using a user-intended Claude Code response: confirm at least one supported allowance window reaches the allowlisted cache and UI, `Check for new observation` performs only a cache read, and disable restores only the exact Codex Limits-owned setup. Do not create a dummy prompt or retain account, session, model, prompt, response, transcript, or project evidence. +- Run Grok schema, protocol, process-group cleanup, timeout/cancellation, output-bound, cache/deletion, and lifecycle tests. Keep real account observations out of deterministic fixtures and public diagnostics. +- Test provider history across restart, repeated-cache deduplication, old-cache seeding, concurrent Claude writes, window/period and source separation, resets and corrections, gaps, exact forecast boundaries, optional Grok starts, bounded active reads, disable/re-enable, and explicit deletion. A single seeded observation must never produce a fabricated past or forecast. +- Preserve OpenCode aggregation fixtures, but run full server-side and lifecycle suites only when a future supported lighter source reopens the Integration. +- Test logs, persistence, and crash diagnostics against the privacy allowlists. +- Run the provisional release budgets on compact histories representing 90 days and ten years, every shipped Integration fixture at its accepted bounds, and the eight-hour lifecycle soak. + +## v1 release boundary and gates + +Claude Code is opt-in `Experimental` and Grok is opt-in `Beta`; release 0.3.0 is accepted with the recorded owner exceptions below. OpenCode remains deferred. The maturity label appears in Settings and the Integration detail header, not beside every value. Grok also displays CLI-version provenance because its custom ACP billing extension is not a versioned public billing API. + +| Release gate | Current evidence | Status | +|---|---|---| +| Grok and OpenCode source decision | Grok 1.0.25 accepts correctly prefixed ACP billing with CLI-owned authentication; OpenCode remains excluded by RSS/write budgets | Passed | +| Bounded Codex history and serialized demand-driven collection | 3,650-day fixture, 32-candidate reconciliation, idle process release, deterministic lifecycle tests | Passed | +| Claude relay, setup, privacy, deletion, executable selection, and boundary behavior | Packaged helper checks and deterministic Release tests | Passed | +| All-enabled idle comparison | Expanded comparison not run; product owner waived it on 2026-09-10 for release 0.3.0; ADR-0015 | Waived for 0.3.0 | +| Eligible Claude account observation | Product owner waived this check on 2026-09-10 for the experimental Claude Code release; ADR-0015 | Waived | +| Eight-hour mixed lifecycle soak | Not run; product owner waived it on 2026-09-10 for release 0.3.0; ADR-0015 | Waived for 0.3.0 | + +`Scripts/validate-release.sh` requires this document to be exactly `Accepted for v1 implementation`. Each required performance row must be `Passed` or explicitly `Waived for VERSION` for the version being released. A version-specific exception does not satisfy later releases; deterministic tests and short comparisons do not count as evidence for an unperformed gate. + +Implementation progress before release acceptance is: + +1. bounded Codex default reader, older-range access, eventual sync, and baseline measurements — implemented and measured on 2026-08-22; +2. shared Integration state, device-local Settings, menu metric selection, and serialized source work — implemented and deterministically tested on 2026-08-22; +3. Claude Code allowance, setup, exact disable, and app-owned data deletion — implemented, packaged, and deterministically tested on 2026-08-22; eligible-account validation is waived for the experimental release and lifecycle soak is waived for 0.3.0; +4. Grok billing transport, validated snapshot model, Settings/workspace/menu integration — implemented on 2026-09-10, including retained Grok/Claude history and burndown charts, with a successful compiled collector read and 644 passing Release tests; signed native chart QA passed; expanded performance gates are waived for 0.3.0; +5. OpenCode remains deferred until a supported lighter source passes its gates. + +The normal acceptance criteria, subject to the explicit 0.3.0 exceptions above, are: + +- provider spikes pass or narrow the scope explicitly; Grok has a working authenticated ACP source and OpenCode remains narrowed out of v1; +- the Codex-only baseline and expanded all-enabled budgets are reproducible; the 2026-08-22 Codex-plus-Claude comparison does not cover Grok, and the expanded idle comparison and eight-hour soak remain unverified; +- bounded Codex history has accepted default-reader, older-range, and eventual-sync implementation paths; +- provider measurement rules have deterministic fixtures; +- no open lifecycle, privacy, accessibility, or data-selection decision remains. + +v1 excludes Claude and Grok OpenTelemetry, Grok Build Local Activity, OpenCode Go allowance, Claude Organization analytics, xAI API team billing, new Assisted Insights runners, per-capability visibility switches, and user-configurable Overview cards. + +## Normative references + +- [Domain language](../../CONTEXT.md) +- [Measurement contract](../MEASUREMENT-CONTRACT.md) +- [Product language](../PRODUCT-LANGUAGE.md) +- [CodexBar method comparison](../research/codexbar-method-comparison-2026-08-21.md) +- [Current Grok validation](../research/grok-build-validation-2026-09-10.md) +- [Historical v1 validation spike results](../research/multi-integration-v1-validation-spikes-2026-08-22.md) +- [Demand-driven bounded collection](../adr/0012-demand-driven-bounded-integration-collection.md) +- [Capability-driven Integration surfaces](../adr/0013-capability-driven-integration-surfaces.md) +- [Bounded range and eventual history reconciliation](../adr/0014-bounded-range-and-eventual-history-reconciliation.md) diff --git a/docs/releasing.md b/docs/releasing.md index 4017d1f..dc83343 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -14,9 +14,9 @@ Losing the EdDSA private key prevents ad-hoc-signed installations from trusting ## Release flow 1. Ask Codex to prepare a release and provide the stable version number. -2. Codex runs tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`. +2. Codex confirms that the multi-integration PRD is `Accepted for v1 implementation` and that `All-enabled idle comparison` and `Eight-hour mixed lifecycle soak` are recorded as `Passed` or explicitly `Waived for VERSION` for that release, runs the Release tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`. The eligible Claude account observation is waived for the experimental release, and both performance checks are waived specifically for 0.3.0 by [ADR-0015](adr/0015-ship-claude-as-experimental.md). No unperformed check is recorded as passed. The validator checks the status and both required rows; it does not block development while they remain pending. The historical Codex-plus-Claude idle comparison does not cover the restored Grok scope or the new provider-local history readers. QA includes history persistence, old-cache seeding, reset and forecast boundaries, and separate Claude/Grok data deletion. 3. Run `Scripts/validate-release.sh VERSION` and the `Release` workflow with `dry_run` enabled. -4. Inspect the universal app archive, signed `appcast.xml`, generated notes, and workflow result. +4. Inspect the universal app archive, both universal app/helper executables, signed `appcast.xml`, generated notes, and workflow result. 5. Run the workflow with `dry_run` disabled. It creates a Draft Release only. 6. Inspect the draft and explicitly tell Codex to publish it. diff --git a/docs/research/claude-code-integration-feasibility-2026-08-18.md b/docs/research/claude-code-integration-feasibility-2026-08-18.md new file mode 100644 index 0000000..8655fed --- /dev/null +++ b/docs/research/claude-code-integration-feasibility-2026-08-18.md @@ -0,0 +1,354 @@ +# Claude Code jako dodatkowy vendor — wykonalność integracji + +**Data:** 2026-08-18\ +**Data dostępu do źródeł:** 2026-08-18\ +**Zakres:** aplikacja `codex-limits`, Claude Code CLI, Claude Agent SDK, Claude API oraz administracyjne API Anthropic\ +**Źródła:** wyłącznie oficjalna dokumentacja Anthropic i oficjalne repozytoria Anthropic + +> **Status implementacyjny:** ten dokument zachowuje szeroki research wykonalności, ale jego warianty historii, modelu i sesji nie są autoryzacją v1. Normatywny zakres wyznaczają [PRD multi-integracji](../prd/multi-integration-workspace.md) oraz [wyniki spike'ów](multi-integration-v1-validation-spikes-2026-08-22.md): v1 przechowuje tylko ostatni allowlisted snapshot limitów, nie zapisuje modelu ani identyfikatora sesji i modyfikuje wyłącznie user settings po potwierdzeniu. + +## Werdykt + +Warto dodać **ograniczoną, lokalną integrację Claude Pro/Max w wersji beta**, opartą na danych, które aktywny Claude Code przekazuje do skonfigurowanego przez użytkownika `statusLine`. Da się z nich uzyskać wykorzystanie i reset okien `five_hour` oraz `seven_day`, a następnie odwzorować siedem dni jako limit główny, a pięć godzin jako limit dodatkowy. + +Nie należy obiecywać pełnej równoważności z obecną integracją Codex. Claude Code nie udostępnia odpowiednika `codex app-server`, z którego zewnętrzna aplikacja może pasywnie i na żądanie odczytać kompletny stan konta. W szczególności brak wspieranego, indywidualnego API do pobierania bieżącego wykorzystania subskrypcji, stabilnej tożsamości konta, dziennych bucketów tokenowych, kredytów i pełnej historii. Dane `statusLine` są **ostatnio zaobserwowanym stanem podczas aktywności Claude Code**, nie gwarantowanym odczytem „live”. + +Nie należy okresowo uruchamiać `claude -p`, aby wymusić odświeżenie limitów. Takie wywołanie zużywa limit, tworzy lub modyfikuje sesję i uruchamia agenta. Ponadto Anthropic zabrania bez wcześniejszej zgody oferowania logowania Claude.ai lub kierowania żądań produktu zewnętrznego przez poświadczenia i limity planów Free/Pro/Max. Dla funkcji, w których aplikacja sama pyta model, właściwym kontraktem jest klucz API użytkownika albo obsługiwany dostawca chmurowy. + +## Obecny kontrakt aplikacji + +### Pobieranie danych konta Codex + +Obecna implementacja nie ma abstrakcji vendora. `CodexClient` jest bezpośrednim aktorem uruchamiającym proces `codex app-server --stdio` i utrzymującym sesję JSONL po stdio. Wykrywanie CLI obejmuje tylko `/opt/homebrew/bin/codex` i `/usr/local/bin/codex` (`Sources/CodexLimits/CodexClient.swift`). + +W pojedynczym odświeżeniu aplikacja wysyła równolegle: + +- `account/rateLimits/read`, +- `account/usage/read`, +- `account/read` z `refreshToken: false`. + +Z odpowiedzi buduje `CodexFetchResult` i `UsageSnapshot`. Kontrakt produktu zakłada m.in. główne okno o `windowDurationMins == 10080`, dodatkowe okna, dzienne buckety tokenowe, fakty o koncie, kredyty, kontrolę wydatków i bankowane resety. Adres e-mail pełni funkcję stabilnej tożsamości partycjonującej historię. Zob. także [`docs/MEASUREMENT-CONTRACT.md`](../MEASUREMENT-CONTRACT.md). + +### Sesje, lokalna aktywność i funkcje wspomagane + +Drugi tor danych jest również specyficzny dla Codex: + +- `thread/list` i `thread/read` przez `ReadOnlyThreadProjectionSource`, +- przyrostowe czytanie `~/.codex/sessions` jako rollout JSONL, +- enum źródła ograniczony do `codex-rollout-jsonl` i `codex-app-server-thread-list`, +- ścisła zgodność z konkretną wersją schematu/CLI. + +„Analyze with Codex” ma osobny, rozbudowany przepływ: `model/list`, izolowane `thread/start`, `turn/start` i `turn/interrupt` oraz porównanie limitów przed i po operacji. Dodanie Claude jako źródła limitów nie czyni automatycznie tej funkcji wielovendorową. + +### Rzeczywiste punkty rozszerzeń + +Najbardziej użyteczne istniejące szwy to: + +- `UsageMonitor` przyjmuje wstrzykiwane `fetchUsage: () async throws -> CodexFetchResult`; +- `UsageSnapshot` jest w dużej mierze neutralnym modelem limitów i historii; +- lokalny collector przyjmuje opcjonalne źródło projekcji wątków. + +To nie jest jednak gotowy interfejs providera. Typ wyniku, błędy, trwałe klucze, tożsamość konta, nazwy źródeł, UI i część logiki domenowej nadal są nazwane lub modelowane pod Codex. Najmniejsza sensowna zmiana architektoniczna powinna wydzielić tylko **źródło snapshotu konta i identyfikator vendora**, nie uniwersalną abstrakcję wszystkich funkcji agenta. + +## Co oficjalnie udostępnia Claude Code + +### CLI i formaty wyjścia + +Claude Code obsługuje tryb nieinteraktywny przez `claude -p`. CLI oferuje: + +- `--output-format text`, `json` lub `stream-json`, +- `--input-format text` lub `stream-json`, +- opcjonalne częściowe zdarzenia strumieniowe, +- `--json-schema` do walidacji wyniku strukturalnego, +- `--model` z aliasem lub pełnym identyfikatorem, +- `--max-budget-usd` i `--max-turns`, +- `--session-id`, `--resume`, `--continue` i `--fork-session`, +- wyłączenie trwałości sesji. + +Wynik JSON/SDK zawiera identyfikator sesji i metadane. Końcowy komunikat `result` ma m.in. czas, liczbę tur, łączne `usage`, użycie per model oraz szacowany `total_cost_usd`. Strumień `stream-json` jest sekwencją rekordów JSON rozdzielonych znakami nowej linii. + +Źródła: [CLI reference](https://code.claude.com/docs/en/cli-reference), [headless mode](https://code.claude.com/docs/en/headless), [Agent SDK — TypeScript types](https://code.claude.com/docs/en/agent-sdk/typescript), [streaming vs single mode](https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode). + +**Znaczenie dla produktu:** jest to dobry kontrakt dla wywołań modelu inicjowanych przez aplikację, ale zły mechanizm pasywnego monitora subskrypcji. Każde zapytanie jest realną pracą agenta i może kosztować pieniądze lub limit. + +### Sesje + +Sesję można wznowić po jej ID albo sforkować. Lokalne sesje Claude Code są zapisywane jako JSONL w `~/.claude/projects//.jsonl`; domyślne czyszczenie nieaktywnych sesji następuje po 30 dniach, o ile konfiguracja nie mówi inaczej. Lokalizację konfiguracji można zmienić przez `CLAUDE_CONFIG_DIR`, a sesję z `-p` można uruchomić bez trwałego zapisu. + +Agent SDK ma lokalne operacje `list_sessions`, `get_session_info` oraz `get_session_messages`. Według oficjalnego cookbooka czytają pliki lokalne i nie uruchamiają subprocessu ani API. To obiecująca późniejsza ścieżka dla listy aktywności, lecz nie daje limitów konta, a zależność od SDK/runtime'u oraz prywatność treści wymagają osobnego projektu. + +Źródła: [Claude Code sessions](https://code.claude.com/docs/en/sessions), [Agent SDK sessions](https://code.claude.com/docs/en/agent-sdk/sessions), [official session-browser cookbook](https://platform.claude.com/cookbook/claude-agent-sdk-05-building-a-session-browser). + +### Modele + +CLI przyjmuje aliasy (`default`, `sonnet`, `opus`, `haiku` i warianty providerów) albo pełne identyfikatory. Alias `default` zależy od konta i może zmieniać się wraz z aktualizacjami Claude Code. Dla wywołań API dostępne modele można pobrać z `GET /v1/models`. + +Integracja nie powinna utrwalać znaczenia aliasu jako stałego modelu. W snapshotach/telemetrii należy zachować otrzymany pełny `model.id`, a alias traktować wyłącznie jako wybór użytkownika. + +Źródła: [model configuration](https://code.claude.com/docs/en/model-config), [Models API](https://platform.claude.com/docs/en/api/models/list). + +## Autoryzacja i rozdział subskrypcji od API + +Claude Code może korzystać z: + +- konta Claude.ai z planem subskrypcyjnym, +- konta Anthropic Console / klucza API, +- Amazon Bedrock, +- Google Vertex AI, +- Microsoft Foundry, +- bramy z tokenem bearer lub helperem klucza, +- długowiecznego setup tokenu. + +Na macOS poświadczenia Claude.ai są przechowywane w Keychain. Aplikacja monitorująca nie powinna ich czytać ani kopiować. Użytkownik powinien instalować i logować oficjalne CLI samodzielnie. + +CLI udostępnia też `claude auth status` do sprawdzenia bieżącego stanu uwierzytelnienia. Oficjalna dokumentacja nie ustanawia jednak trwałego schematu JSON tej komendy ani stabilnego identyfikatora konta. Można jej później użyć jako wersjonowanego, best-effort health checku, ale nie jako kontraktu tożsamości do łączenia historii. + +Najważniejsze ograniczenie produktowe brzmi: bez wcześniejszej zgody Anthropic deweloper zewnętrzny nie może oferować w swoim produkcie logowania przez Claude.ai ani kierować zapytań przez poświadczenia i limity planów Free, Pro lub Max użytkownika. Dokumentacja Agent SDK nakazuje dla agentów w produktach zewnętrznych użyć uwierzytelniania kluczem API. To rozdziela dwa przypadki: + +1. **Pasywna obserwacja lokalnego CLI użytkownika** — możliwa bez przejęcia poświadczeń, jeżeli aplikacja tylko odbiera dane, które oficjalny Claude Code przekazuje lokalnemu `statusLine`. +2. **Wywoływanie Claude przez aplikację** — wymaga osobnego klucza API/chmury albo formalnej zgody Anthropic; nie powinno korzystać z subskrypcyjnego OAuth Claude.ai. + +Źródła: [Claude Code authentication](https://code.claude.com/docs/en/authentication), [Agent SDK quickstart](https://code.claude.com/docs/en/agent-sdk/quickstart), [legal and compliance](https://code.claude.com/docs/en/legal-and-compliance), [API authentication](https://platform.claude.com/docs/en/manage-claude/authentication), [API overview](https://platform.claude.com/docs/en/api/overview). + +| Wariant | Rozliczenie / limit | Dane dostępne lokalnie | Właściwe zastosowanie | +|---|---|---|---| +| Claude.ai Pro/Max w oficjalnym CLI | pięcio- i siedmiodniowe allowance planu | `statusLine`, a w sesji SDK także `RateLimitEvent` | pasywna obserwacja stanu aktywnego CLI; bez wywołań agenta przez produkt zewnętrzny | +| Anthropic API key | usage-based billing oraz RPM/ITPM/OTPM | usage wyniku, estymowany koszt, nagłówki rate-limit | funkcje aplikacji rzeczywiście wywołujące Claude | +| Bedrock / Vertex / Foundry | zasady i rozliczenie danego providera | zależne od providera i konfiguracji | firmowe wywołania modelu, osobny adapter rozliczeń | +| Console organization | API billing + dane organizacyjne | Admin Usage & Cost / Claude Code Analytics | raportowanie zespołu/organizacji, nie indywidualny allowance Pro/Max | +| Claude Enterprise | kontrakt enterprise | Enterprise Analytics API | centralne raportowanie organizacji, inny produkt niż lokalny monitor subskrypcji | + +## Jakie dane o użyciu i limitach są dostępne + +### 1. `statusLine`: najlepsze źródło dla Pro/Max + +Claude Code pozwala skonfigurować pojedynczy skrypt `statusLine`. CLI wielokrotnie wysyła do niego JSON na stdin. Schemat obejmuje m.in. wersję CLI, ID sesji, model, koszt sesji, kontekst oraz `rate_limits`. + +Udokumentowane okna to: + +- `rate_limits.five_hour.used_percentage` i `resets_at`, +- `rate_limits.seven_day.used_percentage` i `resets_at`. + +`used_percentage` jest liczbą od 0 do 100, a `resets_at` czasem Unix w sekundach. Dokumentacja zaznacza, że `rate_limits` pojawia się tylko u subskrybentów Claude.ai Pro/Max, dopiero po pierwszej odpowiedzi API, a poszczególne okna mogą być nieobecne. + +Konsekwencje: + +- jest to stan zdarzeniowy, nie endpoint odpytywany na żądanie; +- gdy Claude Code nie działa, cache się nie odświeża; +- pierwszy rekord sesji może nie mieć limitów; +- pojedynczy wpis konfiguracyjny może kolidować z istniejącym status line użytkownika; +- JSON zawiera znacznie więcej informacji niż potrzebujemy, więc relay powinien zapisywać tylko minimalne pola i nigdy surowy payload. + +Źródło: [Claude Code status line](https://code.claude.com/docs/en/statusline). + +### 2. `RateLimitEvent` Agent SDK: bogatsze, lecz niepasywne + +Python Agent SDK dokumentuje `RateLimitEvent` z `RateLimitInfo`: + +- `status`: `allowed`, `allowed_warning` albo `rejected`, +- `rate_limit_type`: `five_hour`, `seven_day`, `seven_day_opus`, `seven_day_sonnet` albo `overage`, +- `resets_at` jako Unix seconds, +- `utilization` od 0 do 1, +- status overage, reset i powód wyłączenia, +- `session_id`, UUID zdarzenia i surowe dane. + +Jest to atrakcyjny, strukturalny kontrakt, ale zdarzenie należy do sesji prowadzonej przez SDK. Oficjalne API nie dokumentuje pasywnego „podłączenia” do cudzej, już działającej sesji Claude Code. Uruchomienie pustego/dummy promptu wyłącznie po to, aby dostać event, zużywa limit i wchodzi w opisane wyżej ograniczenie auth dla produktu zewnętrznego. + +Źródło: [Agent SDK — Python types](https://code.claude.com/docs/en/agent-sdk/python). + +### 3. Tokeny i koszt pojedynczego wywołania + +Komunikaty asystenta zawierają zużycie danego kroku, a końcowy `ResultMessage` skumulowane zużycie całego zapytania i `total_cost_usd`. Usage rozróżnia input, output, cache read i cache creation oraz może być podzielone per model. + +Anthropic zastrzega, że koszt SDK jest estymacją po stronie klienta: może różnić się od rachunku wskutek zmiany cen, nieznanych identyfikatorów modeli lub umów niestandardowych. Do rozliczeń organizacji należy używać Usage & Cost Admin API. + +Źródła: [Agent SDK cost tracking](https://code.claude.com/docs/en/agent-sdk/cost-tracking), [TypeScript result types](https://code.claude.com/docs/en/agent-sdk/typescript), [Python result types](https://code.claude.com/docs/en/agent-sdk/python), [Claude Code costs](https://code.claude.com/docs/en/costs). + +### 4. OpenTelemetry: dobre dla lokalnej aktywności, nie dla pozostałego limitu + +Claude Code może opcjonalnie eksportować metryki i zdarzenia przez OTLP albo Prometheus. Oficjalny schemat obejmuje m.in.: + +- `claude_code.cost.usage`, +- `claude_code.token.usage` z input/output/cache read/cache creation, +- sesje, linie kodu, commity, PR-y i aktywny czas, +- event `claude_code.api_request` z estymowanym kosztem, tokenami, modelem, czasem i ID requestów, +- atrybucję do agenta, skilla, pluginu, źródła zapytania i poziomu effort. + +To lepszy długoterminowy kanał do lokalnych statystyk niż parsowanie niepublicznego kształtu transcriptów. Oficjalny schemat OTel nie udostępnia jednak pozostałego procentu ani resetu okna subskrypcji. + +Źródło: [Claude Code monitoring usage](https://code.claude.com/docs/en/monitoring-usage). + +### 5. Bezpośrednie Claude API: inne limity niż subskrypcja + +Messages API jest bezstanowe — klient przesyła pełną historię. Odpowiedzi podają input/output/cache usage; streaming wykorzystuje SSE (`message_start`, content block events, `message_delta`, `message_stop`), a końcowe użycie jest aktualizowane w `message_delta`. + +Limity API to token-bucket RPM, ITPM i OTPM, a nie pięcio- i siedmiodniowe allowance Claude Code. Nagłówki odpowiedzi obejmują m.in.: + +- `retry-after`, +- `anthropic-ratelimit-requests-{limit,remaining,reset}`, +- `anthropic-ratelimit-tokens-{limit,remaining,reset}`, +- analogiczne nagłówki input/output tokenów. + +Reset ma format RFC 3339, a `remaining` może być zaokrąglone i odzwierciedla najbardziej restrykcyjny aktywny limit. Tych wartości nie wolno przedstawiać jako procentu tygodniowej subskrypcji. + +Źródła: [Messages API](https://platform.claude.com/docs/en/api/typescript/messages/create), [streaming](https://platform.claude.com/docs/en/build-with-claude/streaming), [working with Messages](https://platform.claude.com/docs/en/build-with-claude/working-with-messages), [API rate limits](https://platform.claude.com/docs/en/api/rate-limits). + +### 6. API administracyjne: wiarygodne, ale organizacyjne + +Anthropic udostępnia trzy istotne klasy raportów: + +- Usage & Cost API dla organizacji Console, z bucketami minutowymi/godzinnymi/dziennymi i podziałem m.in. na model i workspace; +- Claude Code Analytics API, z dziennymi statystykami per użytkownik: sesje, linie kodu, commity, PR-y, narzędzia, tokeny i szacowany koszt per model; +- Enterprise Analytics API z produktowym usage/cost. + +Wymagają Admin API key albo Analytics API key, nie są dostępne dla indywidualnego konta i mają inną świeżość (np. Claude Code Analytics może opóźniać się około godziny). Cost report zwraca kwotę jako dziesiętny string w centach; implementacja finansowa powinna używać typu dziesiętnego, nie `Double`. + +To dobry późniejszy vendor „Claude Organization”, ale nie zastępuje widoku bieżącego allowance Pro/Max. + +Źródła: [Usage & Cost Admin API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api), [Cost Report schema](https://platform.claude.com/docs/en/api/admin/cost_report), [Claude Code Analytics API](https://platform.claude.com/docs/en/manage-claude/claude-code-analytics-api), [Claude Code usage schema](https://platform.claude.com/docs/en/api/admin/usage_report), [Analytics APIs overview](https://platform.claude.com/docs/en/manage-claude/analytics-api), [Enterprise cost report](https://platform.claude.com/docs/en/api/admin/analytics/cost). + +## Licencje i zależności + +Repozytorium `anthropics/claude-code` jest publiczne, ale Claude Code CLI nie jest projektem open-source na licencji pozwalającej na swobodne bundlowanie. Jego `LICENSE.md` wskazuje „all rights reserved” i odsyła do Commercial Terms. To samo dotyczy natywnego binarium dołączanego przez TypeScript Agent SDK. Sam TypeScript SDK również wskazuje Commercial Terms. + +Python Agent SDK wrapper ma licencję MIT, lecz nie relicencjonuje natywnego Claude Code ani jego warunków użycia. Oficjalny TypeScript SDK do bezpośredniego Claude API jest MIT. + +Wniosek praktyczny: + +- w MVP wykrywamy osobno zainstalowane oficjalne CLI; nie kopiujemy i nie redystrybuujemy binarium; +- przed ewentualnym bundlowaniem Agent SDK/binarium trzeba zaakceptować aktualne Commercial Terms i przejść przegląd prawny; +- nie nazywamy repozytorium Claude Code „open source” tylko dlatego, że kod/artefakty są widoczne na GitHubie. + +Źródła: [Claude Code license](https://github.com/anthropics/claude-code/blob/main/LICENSE.md), [Claude Code repository](https://github.com/anthropics/claude-code), [TypeScript Agent SDK license](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/LICENSE.md), [TypeScript Agent SDK repository](https://github.com/anthropics/claude-agent-sdk-typescript), [Python Agent SDK license](https://github.com/anthropics/claude-agent-sdk-python/blob/main/LICENSE), [Anthropic TypeScript API SDK package](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/package.json). + +## Ocena trudności + +| Obszar | Trudność | Uzasadnienie | +|---|---:|---| +| Wykrycie oficjalnego CLI i wersji | łatwe | Natywny installer i Homebrew mają udokumentowane ścieżki; potrzebna jest też `~/.local/bin/claude`, a nie tylko obecne ścieżki Codex. | +| Mapowanie `seven_day` i `five_hour` na `UsageSnapshot` | łatwe | Procent i reset mają jawny schemat; trzeba zachować semantykę „used”, źródło i czas obserwacji. | +| Wyświetlenie ostatnio zaobserwowanych limitów | łatwe–średnie | UI i snapshot są blisko potrzeb, ale produkt nie może sugerować odczytu live. | +| Relay `statusLine` z bezpiecznym setup/uninstall | średnie | Jeden slot konfiguracyjny, możliwy istniejący skrypt, wiele procesów, out-of-order events, atomowy cache i minimalizacja danych. | +| Historia i prognoza tylko z próbek aplikacji | średnie | Da się użyć istniejącego silnika, ale wymaga partycjonowania vendora, fresh/stale semantics i zakazu fabrykowania dziennych bucketów. | +| Lokalna aktywność z OTel | średnie | Schemat jest wspierany, lecz aplikacja musi utrzymać receiver, konfigurację, retry i deduplikację; nadal brak allowance. | +| Lista lokalnych sesji przez Agent SDK | średnie | Read-only API jest wspierane, ale trzeba dodać runtime/SDK lub osobny helper, politykę prywatności i wersjonowanie. | +| Organizacyjne usage/cost | średnie–trudne | API są formalne, ale dochodzą klucze Admin/Analytics, Keychain, sieć, paginacja, opóźnienie i inny model produktu. | +| Pełna równoważność indywidualnego konta z Codex | trudne / niewykonalne obecnie | Brak pasywnego API dla usage, stable identity, dziennych bucketów, kredytów i bankowanych resetów. | +| „Live refresh” Pro/Max bez aktywności Claude | niewykonalne bez skutków ubocznych | Wymagałoby wykonania zapytania agenta, które zużywa limit i narusza oczekiwany pasywny charakter. | +| „Analyze with Claude” przez subskrypcję użytkownika | trudne prawnie i technicznie | Third-party product nie może bez zgody kierować żądań przez OAuth/limity Free/Pro/Max; osobny tryb API-key jest możliwy. | +| Task Tree w pełnej parytecie | trudne | Brak odpowiednika `thread/list`/`thread/read`, osobne subagenty, retencja i zmienność transcriptów; wymaga osobnego kontraktu UX i danych. | +| Poprawna historia dla wielu kont | trudne | `statusLine` nie dokumentuje stabilnej tożsamości konta. Bez niej nie wolno łączyć próbek różnych loginów. | + +## Rekomendowany minimalny wariant + +### Produkt + +Nazwa funkcji: **Claude Pro/Max (local beta)**. Widok powinien mówić „ostatnio zaobserwowane podczas aktywności Claude Code” i pokazywać czas ostatniego eventu. + +Zakres MVP: + +1. Użytkownik sam instaluje i loguje oficjalne Claude Code. +2. Po świadomym opt-in aplikacja konfiguruje mały lokalny relay `statusLine` wyłącznie w user settings albo podaje instrukcję ręcznej konfiguracji. Nie skanuje ustawień project, local-project ani managed; wyższe zakresy pozostają nietknięte i mogą przesłonić wpis użytkownika. +3. Jeżeli user settings zawiera już `statusLine`, MVP nie nadpisuje go automatycznie. Pokazuje konflikt i instrukcję manualną. Łańcuchowanie dowolnego polecenia użytkownika można zaprojektować później. +4. Relay waliduje wejście, odrzuca niepotrzebne pola i atomowo zapisuje wyłącznie: + - wersję schematu relay, + - `observedAt`, + - wersję CLI, + - `five_hour.used_percentage` i `resets_at`, + - `seven_day.used_percentage` i `resets_at`. +5. Aplikacja czyta cache lokalnie. `seven_day` staje się limitem głównym, `five_hour` dodatkowym; `remaining = 100 - used_percentage`. +6. Brak danych jest normalnym stanem. Settings wyjaśnia, że snapshot pojawia się po pierwszej odpowiedzi na kwalifikującym się koncie Pro/Max. Dane po ustalonym TTL są jawnie oznaczone jako nieaktualne, nie zerowane. +7. v1 zachowuje wyłącznie ostatni poprawny snapshot. Nie tworzy historii, sztucznych dziennych token buckets, kosztów, faktów lifetime ani kredytów. +8. Snapshot Claude jest lokalny dla instalacji. Synchronizacja między urządzeniami i automatyczne łączenie pozostają wyłączone, dopóki nie powstanie wspierany identyfikator konta i osobna decyzja retencyjna. +9. Local Task Tree i „Analyze with Codex” pozostają Codex-only, z jawną etykietą. + +### Minimalna zmiana architektury + +Nie budować od razu „uniwersalnego agent runtime”. Wystarczy mały kontrakt źródła account snapshot, np. semantycznie: + +```swift +enum VendorID { case codex, claudeCode } + +struct VendorFetchResult { + let vendor: VendorID + let snapshot: UsageSnapshot + let freshness: SourceFreshness + let accountIdentity: VendorAccountIdentity? + let capabilities: VendorCapabilities +} +``` + +Capabilities powinny jawnie określać brak: account daily tokens, lifetime facts, credits, local activity, sync identity i assisted analysis. Dzięki temu UI nie wywnioskuje funkcji z pustych tablic i nie nazwie braku danych zerem. + +Codex zachowuje obecny client i pełny zestaw możliwości. Claude dostaje osobny, mały adapter cache relay. Dopiero realna druga implementacja pokaże, które nazwy i typy warto uczynić neutralnymi. + +## Etapy po MVP + +1. **Provider boundary:** vendor w snapshotach, historii i UI; bez zmiany transportu Codex. +2. **Claude Pro/Max beta:** read-only cache z `statusLine`, current allowance i reset, jawna świeżość. +3. **Lokalna aktywność:** opcjonalny OTel receiver; ewentualnie Agent SDK read-only sessions po osobnej ocenie dependency/privacy. +4. **Claude Organization:** Claude Code Analytics oraz Usage & Cost Admin API, jako oddzielny typ konta. +5. **Assisted analysis:** tylko w trybie BYO API key / Bedrock / Vertex / Foundry, nigdy automatycznie przez subskrypcję Claude.ai bez zgody Anthropic. + +## Warunki go/no-go + +**Go:** opt-in beta pokazująca ostatnio zaobserwowane okna Pro/Max, bez odczytu credentiali, bez wywoływania modelu i bez obietnicy live/parytetu. + +**No-go:** marketing funkcji jako pełnego odpowiednika Codex; polling przez dummy prompts; przejmowanie OAuth Claude.ai; nadpisywanie istniejącego status line; mieszanie RPM/TPM API z allowance Pro/Max; synchronizowanie historii bez stabilnej tożsamości konta. + +Przed dystrybucją funkcji, która sama uruchamiałaby Claude Agent SDK z subskrypcyjną sesją użytkownika, potrzebna jest jednoznaczna zgoda Anthropic i przegląd aktualnych Commercial Terms. + +## Źródła pierwotne — indeks + +Wszystkie źródła odwiedzono 2026-08-18. + +### Claude Code i Agent SDK + +- [CLI reference](https://code.claude.com/docs/en/cli-reference) +- [Headless mode](https://code.claude.com/docs/en/headless) +- [Authentication](https://code.claude.com/docs/en/authentication) +- [Legal and compliance](https://code.claude.com/docs/en/legal-and-compliance) +- [Status line](https://code.claude.com/docs/en/statusline) +- [Settings scopes and precedence](https://code.claude.com/docs/en/settings) +- [Costs](https://code.claude.com/docs/en/costs) +- [Errors and usage limits](https://code.claude.com/docs/en/errors) +- [Sessions](https://code.claude.com/docs/en/sessions) +- [Hooks](https://code.claude.com/docs/en/hooks) +- [Monitoring usage / OpenTelemetry](https://code.claude.com/docs/en/monitoring-usage) +- [Data usage](https://code.claude.com/docs/en/data-usage) +- [How Claude Code works](https://code.claude.com/docs/en/how-claude-code-works) +- [Setup](https://code.claude.com/docs/en/setup) +- [Model configuration](https://code.claude.com/docs/en/model-config) +- [Agent SDK quickstart](https://code.claude.com/docs/en/agent-sdk/quickstart) +- [Agent SDK TypeScript](https://code.claude.com/docs/en/agent-sdk/typescript) +- [Agent SDK Python](https://code.claude.com/docs/en/agent-sdk/python) +- [Agent SDK cost tracking](https://code.claude.com/docs/en/agent-sdk/cost-tracking) +- [Agent SDK sessions](https://code.claude.com/docs/en/agent-sdk/sessions) +- [Agent loop](https://code.claude.com/docs/en/agent-sdk/agent-loop) +- [Streaming vs single mode](https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode) +- [Official session-browser cookbook](https://platform.claude.com/cookbook/claude-agent-sdk-05-building-a-session-browser) + +### Claude API i administracja + +- [API authentication](https://platform.claude.com/docs/en/manage-claude/authentication) +- [API overview](https://platform.claude.com/docs/en/api/overview) +- [Messages API](https://platform.claude.com/docs/en/api/typescript/messages/create) +- [Streaming Messages](https://platform.claude.com/docs/en/build-with-claude/streaming) +- [Working with Messages](https://platform.claude.com/docs/en/build-with-claude/working-with-messages) +- [Models API](https://platform.claude.com/docs/en/api/models/list) +- [API rate limits](https://platform.claude.com/docs/en/api/rate-limits) +- [Usage & Cost Admin API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api) +- [Cost Report schema](https://platform.claude.com/docs/en/api/admin/cost_report) +- [Claude Code Analytics API](https://platform.claude.com/docs/en/manage-claude/claude-code-analytics-api) +- [Claude Code usage report schema](https://platform.claude.com/docs/en/api/admin/usage_report) +- [Analytics APIs overview](https://platform.claude.com/docs/en/manage-claude/analytics-api) +- [Enterprise cost report](https://platform.claude.com/docs/en/api/admin/analytics/cost) + +### Oficjalne repozytoria i licencje + +- [Claude Code](https://github.com/anthropics/claude-code) +- [Claude Code license](https://github.com/anthropics/claude-code/blob/main/LICENSE.md) +- [TypeScript Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript) +- [TypeScript Agent SDK license](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/LICENSE.md) +- [Python Agent SDK license](https://github.com/anthropics/claude-agent-sdk-python/blob/main/LICENSE) +- [Anthropic TypeScript API SDK package metadata](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/package.json) diff --git a/docs/research/codex-limits-user-research-2026-07-27.md b/docs/research/codex-limits-user-research-2026-07-27.md new file mode 100644 index 0000000..c015bde --- /dev/null +++ b/docs/research/codex-limits-user-research-2026-07-27.md @@ -0,0 +1,421 @@ +# Codex limits after GPT‑5.6: user research and product opportunities + +Date: 2026-07-27\ +Primary observation window: 2026-07-09–2026-07-27\ +Product: `codex-limits` + +## Executive summary + +The strongest user need is no longer another percentage meter. Users want to answer four decisions: + +1. **Will my allowance last until reset?** +2. **What consumed it, and was that consumption normal?** +3. **Which model, reasoning level, task, or workflow should I use next?** +4. **Should I use a banked reset, buy credits, pause work, or wait?** + +The current app already covers the first question with pacing and a forecast. The fork’s banked-reset count, oldest-reset expiry, and runtime/week estimate extend that well. The most valuable next step is to make the forecast **event-aware and explainable**: compare the current window with previous windows, flag abnormal burn, and attribute local usage to models and tasks without pretending that local token logs are identical to OpenAI’s private subscription ledger. + +The evidence for this direction is unusually concentrated. Within the observation window, r/codex created a dedicated [usage-limits megathread](https://www.reddit.com/r/codex/comments/1v42x6r/codex_usage_limits_and_performance_megathread/) after many separate complaints; high-engagement posts reported [one Ultra task consuming a full weekly allowance](https://www.reddit.com/r/codex/comments/1uv7ui6/codex_56_sol_at_ultra_used_100_of_my_weekly_usage/), [one `/goal` consuming a full week](https://www.reddit.com/r/codex/comments/1v6wvg9/did_codex_just_eat_my_entire_weekly_quota_on_one/), and [7% disappearing in five minutes](https://www.reddit.com/r/codex/comments/1v70vl9/7_weekly_limit_gone_in_5_minutes_max_20x_plan/). These are self-selected reports, not proof of a universal quota reduction, but they reveal the questions the product must help users answer. + +## Scope and method + +- GPT‑5.6 became generally available on **July 9, 2026**, so the research window starts there rather than at an assumed later date. [OpenAI launch announcement](https://openai.com/index/gpt-5-6/) and [model release notes](https://help.openai.com/en/articles/9624314-model-release-notes). +- Sources prioritize first-party user reports on Reddit and GitHub issues in `openai/codex`, plus official OpenAI documentation and status reports. +- X was searched, but direct X pages are inconsistently indexable. A small number of original employee posts are useful as reset-event evidence, but X sentiment is not quantified. +- Reddit scores and comment counts are dynamic. They are used only as a rough signal of resonance. +- Reports show perceived or locally measured consumption. They do **not** establish that OpenAI silently lowered every account’s weekly entitlement. +- Recommendations are separated from observations. Features seen in other open-source trackers are listed separately from user demand. + +## Official baseline: what is known and what is not + +### Known + +- OpenAI says local and cloud messages share a five-hour window and that “additional weekly limits may apply.” It publishes approximate five-hour message ranges but not a numeric weekly entitlement. [Current Codex pricing and limits](https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan). +- Usage varies with model, task size, context, reasoning, tool use, retrieval, caching, and execution surface. Similar-looking tasks can therefore consume different amounts. [Current Codex pricing and limits](https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan). +- Codex moved to token-based credit accounting. Current GPT‑5.6 rates per one million input / cached-input / output tokens are: Sol `125 / 12.5 / 750`, Terra `62.5 / 6.25 / 375`, and Luna `25 / 2.5 / 150` credits. [OpenAI Codex rate card](https://help.openai.com/en/articles/20001106-codex-rate-card). +- Codex, ChatGPT Work, and supported agentic features can draw from the same included allowance and credit pool. [OpenAI pricing](https://learn.chatgpt.com/docs/pricing) and [credits documentation](https://help.openai.com/en/articles/12642688-using-credits-for-flexible-usage-in-chatgpt-pluspro). +- Eligible Plus and Pro users received reset banking in June. Banked resets are generally usable for 30 days after grant. [June 11 release note](https://help.openai.com/en/articles/6825453-chatgpt-release-notes) and [referral-promotion terms](https://help.openai.com/en/articles/20001271-codex-referral-promotions). +- Banked resets are not cash or API credits. Purchased credits are used after included usage and expire after 12 months. [Referral-promotion terms](https://help.openai.com/en/articles/20001271-codex-referral-promotions) and [credits documentation](https://help.openai.com/en/articles/12642688-using-credits-for-flexible-usage-in-chatgpt-pluspro). +- The public Codex app-server API exposes quota-window usage, reset time, available earned-reset count, and reset-credit expiry details when the backend provides them. It also exposes an account token-activity summary and daily buckets. [Official app-server README](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api). + +### Not known publicly + +- OpenAI has not published the numeric weekly cap or a formula that converts remaining percentage into hours of runtime. +- OpenAI has not documented `runtime/week`, `suggested pace`, or a stable “messages remaining” conversion as entitlement units. +- OpenAI has not published a changelog proving a permanent weekly-cap reduction caused by GPT‑5.6. +- There is no public guarantee that banked resets are consumed FIFO, nor a general fixed maximum number that can be banked. +- OpenAI has not documented that all local token records map one-to-one onto subscription-limit debits. + +**Product consequence:** `runtime/week`, “hours left,” and “effective cap” must be presented as local estimates with a confidence/staleness label, never as official allowance. + +## User observations and recommendations + +### 1. Users need runway, not a raw percentage + +**Observation** + +Users repeatedly translate the meter into time or work: “one or two days,” “one feature,” “one `/goal`,” or “a normal workday.” A Plus user asked how to make a weekly allowance last after it began disappearing in one or two days and explicitly compared two simultaneous reasoning configurations. [“Any tips for making Codex weekly usage last longer on Plus”](https://www.reddit.com/r/codex/comments/1v1dgqh/any_tips_for_making_codex_weekly_usage_last/). Another user reported a 4.5-hour `/goal` consuming the full weekly quota and asked whether long goals should be split into smaller tasks. [One `/goal` report](https://www.reddit.com/r/codex/comments/1v6wvg9/did_codex_just_eat_my_entire_weekly_quota_on_one/). + +**Recommendation** + +Keep the current pace forecast as the primary product. Add a more direct decision statement: + +- estimated exhaustion date/time; +- estimated gap before scheduled reset; +- “hours at your recent active-work pace,” explicitly labeled as an estimate; +- confidence based on sample count, recency, and whether a reset or model mix changed. + +### 2. Users cannot tell whether a spike came from entitlement, model mix, or agent behavior + +**Observation** + +The megathread contains multiple unchanged-workflow comparisons: users report the same class of tasks costing materially more after the launch window, but they disagree on the cause. Proposed causes include a lower effective quota, model behavior, cache replay, tool polling, and accounting bugs. [Usage-limits megathread](https://www.reddit.com/r/codex/comments/1v42x6r/codex_usage_limits_and_performance_megathread/). OpenAI has previously acknowledged a separate incident in which fraud-prevention systems incorrectly rate-limited some accounts, while saying it did not observe broad degradation. [OpenAI status incident, June 26–29](https://status.openai.com/incidents/01KW2E6W0503W4NXJNCVAG8V6T). + +**Recommendation** + +Add an anomaly layer that says **what changed in the observed data**, not why OpenAI changed it: + +- current burn rate versus the median of the last 3–5 comparable windows; +- spike start time; +- simultaneous changes in model, reasoning, speed, active threads, or reset state; +- neutral labels such as `Higher than your baseline`, not `OpenAI reduced your limit`. + +### 3. Model and reasoning attribution is a high-frequency decision need + +**Observation** + +Fresh discussions compare Sol/Terra/Luna and Medium/High/XHigh/Ultra because users are trying to choose a viable daily driver. The Plus workflow question asks whether XHigh for review and High for implementation wastes usage. [Workflow question](https://www.reddit.com/r/codex/comments/1v1dgqh/any_tips_for_making_codex_weekly_usage_last/). The Ultra report quantifies two runs totaling 1h32m and a full weekly allowance. [Ultra report](https://www.reddit.com/r/codex/comments/1uv7ui6/codex_56_sol_at_ultra_used_100_of_my_weekly_usage/). The megathread includes comparisons where users moved down to Medium but still perceived higher burn. [Megathread](https://www.reddit.com/r/codex/comments/1v42x6r/codex_usage_limits_and_performance_megathread/). + +OpenAI’s current rate card makes this a legitimate cost dimension: Terra is half Sol’s credit rate and Luna one fifth, while reasoning, context, and tool use also affect actual consumption. [Official pricing](https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan). + +**Recommendation** + +Power-user analytics should break local token and estimated-credit usage down by: + +- model; +- reasoning effort; +- speed mode where available; +- day/window; +- foreground thread versus child/subagent. + +The UI should compare like with like and avoid claims such as “Medium is 2× more efficient” unless enough comparable history exists. + +### 4. Thread/task attribution is more actionable than daily totals + +**Observation** + +Users identify costly units as tasks: a `/goal`, an Ultra run, a review, or one feature. They want to know which unit emptied the meter and whether they should split it next time. [One `/goal` report](https://www.reddit.com/r/codex/comments/1v6wvg9/did_codex_just_eat_my_entire_weekly_quota_on_one/), [Ultra report](https://www.reddit.com/r/codex/comments/1uv7ui6/codex_56_sol_at_ultra_used_100_of_my_weekly_usage/), and [workflow discussion](https://www.reddit.com/r/codex/comments/1v4aaim/what_is_your_codex_workflow/). + +**Recommendation** + +Add a local “top consumers” view: + +- thread/task name, start/end, runtime, quota delta, local token total, estimated credits; +- model/reasoning mix; +- subagent count; +- a link back to the local Codex thread where possible. + +Use `quota delta` and `local token estimate` as separate columns. Never imply that local tokens exactly explain the backend percentage. + +### 5. Cache/context rebuild and tool loops are now a concrete diagnostic need + +**Observation** + +One July 22 user analysis reported 118M local tokens and 71% weekly allowance use, with roughly 114.6M cached input tokens. [118M-token analysis](https://www.reddit.com/r/codex/comments/1v3c19s/i_used_118m_codex_tokens_in_one_day_and_consumed/). A separate analysis of ten Sol rollouts reported that 99.77% of local token traffic was input and argued that repeated context replay dominated. [Context-replay analysis](https://www.reddit.com/r/codex/comments/1v4vawj/important_findings_on_cache_and_baked_in_codex/). + +The strongest reproducible evidence is GitHub issue [#35259](https://github.com/openai/codex/issues/35259): in one corrected local reset window, turns whose only action was waiting or polling represented 19.8% of raw local token volume. The author explicitly states that raw local tokens are not the same as subscription usage and found no proof of a silent quota reduction. + +**Recommendation** + +Power-user diagnostics should surface: + +- cached versus uncached input; +- cache-hit ratio; +- input/output ratio; +- context size trend per turn; +- compaction/rebuild events; +- repeated wait/status/tool-only turns; +- repeated identical or near-identical tool calls; +- subagent and auto-review share. + +These are diagnostic signals, not quality scores. A high cache ratio can be economically useful because cached input is cheaper, while a huge cached prefix replayed hundreds of times can still be costly. + +### 6. Reset events make ordinary trend charts misleading + +**Observation** + +Users report balances jumping after refresh, uncertainty over whether a hard reset completed, and conflicting reset dates between surfaces. [Hard-reset inconsistency report](https://www.reddit.com/r/codex/comments/1ur07x0/hard_limit_reset_or_not/) and GitHub issue [#32840](https://github.com/openai/codex/issues/32840). Another GitHub report says merely opening `/usage` appeared to grant a reset, illustrating how unexplained discontinuities are interpreted as bugs. [Issue #34661](https://github.com/openai/codex/issues/34661). + +Promotional/global resets also occurred around the launch period. A July 17 [original Codex-lead X post](https://x.com/thsottiaux/status/2078310751878647932) announced another reset for paid Codex and ChatGPT Work users. These hard resets are not the same thing as a saved reset in the user’s bank. + +**Recommendation** + +Treat resets as first-class events: + +- scheduled window reset; +- detected hard/global reset; +- user-consumed banked reset; +- unknown discontinuity. + +Split forecasts at reset boundaries, annotate charts, and do not interpret a jump from 10% to 100% as negative usage. + +### 7. Banked resets need an advisor, not only a counter + +**Observation** + +Users explicitly ask for banked resets instead of unpredictable hard resets and worry that a reset changes their normal reset timing. [Megathread comments](https://www.reddit.com/r/codex/comments/1v42x6r/codex_usage_limits_and_performance_megathread/). OpenAI says banked resets generally expire 30 days after grant and exposes individual expiry timestamps when available through the app server. [Referral terms](https://help.openai.com/en/articles/20001271-codex-referral-promotions) and [app-server API](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api). + +The sharper failure mode is accidental expiry. A July 27 user planned around the displayed calendar date, only to find the reset gone early in their local morning; replies pointed them to the exact `/usage` expiry time. [“Banked reset and due date”](https://www.reddit.com/r/codex/comments/1v7r85x/banked_reset_and_due_date/). Another community tool was built specifically to show an expiry planner and optionally redeem a reset shortly before it expires. [Banked Reset Safety Net](https://github.com/just-every/banked-reset-safety-net). This supports an explicit reminder use case even if `codex-limits` remains read-only. + +**Recommendation** + +The fork’s `Banked resets` and `Oldest reset expires in` fields are strongly supported. Add a read-only **Reset Guard**: + +- show the exact local expiry date and time as well as a live countdown; +- schedule configurable notifications, with sensible presets such as 24 hours, 2 hours, and 30 minutes; +- make the final notification actionable: open the Codex usage surface or copy the expiry details; +- persist which notification thresholds have fired so refreshes and app restarts do not duplicate them; +- reschedule from a fresh backend snapshot after wake, clock/time-zone changes, and account changes; +- state clearly that the reset is **not** redeemed automatically. + +A later advisor can answer: + +- which reset expires first; +- whether current pace is likely to hit the limit before expiry; +- whether waiting for the scheduled reset would preserve more optionality. + +Do not assume FIFO. Use expiry details returned by the backend and label missing detail. Automatic redemption should remain a separate, explicit opt-in feature: it changes account state, requires idempotency and post-action verification, and should not be bundled with ordinary notifications. + +### 8. Shared-pool and data-freshness ambiguity damages trust + +**Observation** + +OpenAI documents that supported agentic products can share the same allowance. Users nevertheless ask whether ChatGPT Work consumes Codex usage and report conflicting values between `/status`, web, and app. [Shared-pool question](https://www.reddit.com/r/codex/comments/1us5udh/does_the_new_chatgpt_work_consume_codex_usage/) and GitHub issue [#32840](https://github.com/openai/codex/issues/32840). + +**Recommendation** + +Always display: + +- sample timestamp and source; +- whether data came from backend rate limits, backend daily usage, or local JSONL; +- staleness/error state; +- a note that other supported agentic surfaces can consume the shared pool; +- a warning when two observed sources disagree beyond a tolerance. + +## Power-user analytics demand matrix + +| Dimension | User question | Evidence strength in the July 9–27 window | Product interpretation | +|---|---|---:|---| +| Model | “Is Sol the cause; should I use Terra/Luna?” | Strong | Per-model breakdown is high value. | +| Reasoning | “Should review be XHigh and implementation High/Medium?” | Strong | Compare effort only within similar tasks; show sample sizes. | +| Thread/task | “Which `/goal` or feature consumed the week?” | Strong | Per-thread/task leaderboard and quota deltas. | +| Subagents | “Did Ultra or agent fan-out burn the allowance?” | Strong | Parent/child attribution and fan-out diagnostics. | +| Cache/context | “Is repeated context replay the real cost?” | Strong | Cache, context-growth, compaction, and replay views. | +| Tool loops | “Was Codex doing useful work or polling?” | Strong | Wait/poll/tool-only turn share and loop flags. | +| Window comparison | “Why is this reset/window worse than last week?” | Strong | Comparable-window baselines and anomaly detection. | +| Project | “Which repo/client is consuming usage?” | Moderate/adjacent | Useful for power users, but direct fresh demand is weaker than thread/model demand. | +| Usage efficiency | “How much useful work did I get per quota?” | Strong concept, weak automatic metric | Use transparent proxies or an explicit user outcome tag. | + +## Defining “usage efficiency” without misleading users + +No single token-efficiency number measures delivered value. Recommended metrics should form a ladder: + +### Safe descriptive metrics + +- quota percentage per active hour; +- local estimated credits per active hour; +- local tokens per model turn; +- cached/uncached input and output shares; +- wait/poll-only share; +- context growth per turn; +- current-window burn versus the user’s own historical baseline. + +### Useful but interpretive metrics + +- estimated credits per completed thread/task; +- quota delta per completed task; +- subagent overhead share; +- tool-loop overhead share; +- context-replay share. + +### Experimental outcome proxies + +- user marks a task `useful`, `partial`, or `failed`; +- estimated credits per user-confirmed completed task; +- estimated credits per merged PR/commit or verified test pass. + +Lines changed, files touched, or output tokens alone must not be labeled “productivity”: a small bug fix can be more valuable than a large generated diff. + +## Open-source tracker landscape + +This section describes implemented features, not proof that every feature has user demand. + +| Project | Primary implemented ideas | Relevance to `codex-limits` | +|---|---|---| +| [ccusage](https://github.com/ccusage/ccusage) | Local daily/weekly/monthly/session reports, per-model breakdown, cache-create/read columns, estimated cost, JSON export; project grouping exists for supported sources. | Strong reference for stable local token aggregation and export; weak on quota runway and reset decisions. | +| [CodexBar](https://github.com/steipete/CodexBar) | Menu-bar quotas, reset countdowns, local 7/30-day cost estimates, model breakdowns, Codex project totals, multi-account/provider support, staleness/incident UI. [CLI schema](https://github.com/steipete/CodexBar/blob/main/docs/cli.md). | Closest product-shape competitor; demonstrates value of combining allowance and local cost, but has much broader multi-provider scope. | +| [Codex Usage Tracker](https://github.com/douglasmonsky/codex-usage-tracker) | Current v0.26 is a local evidence kernel. Historical [v0.25.1](https://github.com/douglasmonsky/codex-usage-tracker/tree/v0.25.1) exposed thread/model/reasoning/subagent/cache/context-pressure diagnostics and evidence links. | Strong reference for forensic drill-down and data-quality caveats; too heavy for the default menu-bar experience. | +| [OpenAI Codex app-server](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api) | Backend rate-limit snapshots, earned-reset details, expiry timestamps, daily token activity. | Preferred authoritative source for live allowance and reset metadata; does not by itself explain per-thread local behavior. | + +The clearest positioning opportunity is therefore: **Codex Limits is the decision layer between a simple meter and a forensic token dashboard.** + +## Data provenance + +Power-user analytics should preserve the boundary between four data classes. Every metric should carry a source class, observation time, and completeness/confidence state. + +### A. Backend facts from the Codex app server + +`account/rateLimits/read` can provide: + +- `rateLimits` / `rateLimitsByLimitId`; +- `limitId` and `limitName`; +- primary and secondary `usedPercent`, `windowDurationMins`, and `resetsAt`; +- backend limit-reached type when available; +- `rateLimitResetCredits.availableCount`; +- optional per-reset `id`, `grantedAt`, `expiresAt`, `status`, `title`, and `description`. + +`account/usage/read` provides an account token-activity summary and daily buckets. The current app decodes `dailyUsageBuckets[].startDate` and `tokens`. These are backend responses, but daily aggregation still does not attribute usage to a local thread. [Official app-server account API](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api) and current [`CodexClient.swift`](../../Sources/CodexLimits/CodexClient.swift). + +Current implementation note: `CodexClient` presently decodes only the reset `availableCount`, even though the upstream protocol can return expiry details. + +### B. Exact local metadata already stored by Codex or Codex Limits + +Codex Limits stores a bounded local percentage history: + +- `observedAt`; +- `remainingPercent`; +- `resetsAt`. + +It also stores backend daily `date` and `tokens` buckets for forecast bootstrapping. See [`UsageModels.swift`](../../Sources/CodexLimits/UsageModels.swift) and the [shared-history ADR](../adr/0006-user-selected-folder-for-shared-usage-history.md). + +Codex rollout JSONL can contain, depending on client/version/mode: + +- `session_meta`: thread/session ID, timestamp, `cwd`, originator, CLI version, source, and model provider; +- `turn_context`: effective model and reasoning effort, including child-agent context; +- `event_msg/token_count`: cumulative and last-turn input, cached-input, output, reasoning-output, and total tokens; model context-window size; sometimes embedded rate-limit observations; +- lifecycle and diagnostic records such as task start/complete, compaction, tool calls/results, and agent events. + +Examples of these exact fields appear in OpenAI’s repository discussions and issues: [`session_meta` and `cwd`](https://github.com/openai/codex/discussions/12668), [`token_count` usage fields](https://github.com/openai/codex/issues/19022), and effective [model/reasoning in child `turn_context`](https://github.com/openai/codex/issues/34370). + +These files are precise records of what the local client persisted. They are not a stable billing API, can be absent in ephemeral modes, and can vary by Codex version. + +### C. Derived estimates + +The following are product calculations, not facts returned by OpenAI: + +- `remainingPercent = 100 - usedPercent`; +- expected, historical, and safety remaining-at-reset forecasts; +- suggested percentage per day/hour; +- estimated exhaustion time and `runtime/week`; +- anomalous-burn score versus personal history; +- credits or API-equivalent cost calculated from local token buckets and the public rate card; +- per-thread/task/project attribution assembled from local metadata; +- estimated cache/context/tool-loop overhead. + +The current forecast combines recent percentage burn with historical burn and uses daily token history only as a coarse bootstrap. See [`ForecastEngine.swift`](../../Sources/CodexLimits/ForecastEngine.swift). New UI should label these values `Estimate` and expose a short “based on” explanation. + +### D. Not observable from available data + +- the account’s numeric weekly entitlement and OpenAI’s private debit formula; +- the exact backend debit caused by one local turn; +- which other device or shared-pool product caused an unexplained quota change; +- the cause or scope of a global reset that occurred between samples; +- missing, cleared, archived, ephemeral, or corrupted local sessions; +- whether a task’s result was correct, valuable, or “worth” its usage; +- the counterfactual cost and quality of running the same task on another model; +- whether an observed correlation proves a server-side quota change. + +**Required UI rule:** never merge backend quota, backend daily tokens, local rollout tokens, and estimated credits into one unlabeled number. Reconciliation gaps are expected and should be visible. + +## Assessment of the shiptomorrow fork + +The fork is a substantial prototype rather than a small presentation patch. Relative to the current upstream base it adds roughly 2,770 lines, expands the test suite from 18 to 46 test functions, and includes: + +- banked-reset count, earliest expiry, plan label, and used/remaining display; +- a persistent app-server connection and configurable refresh interval; +- weekly-limit history separate from the primary-window history; +- local task-runtime extraction from Codex JSONL; +- estimated active hours per full weekly allowance and a historical pace chart; +- daily-runtime-informed forecasting and guards against implausible percentage increases; +- settings for lookback, pause treatment, prior-window display, and history reset. + +The banked-reset fields are sourced from supported app-server data and can move upstream with low interpretive risk. The weekly runtime work is useful but currently reads task timestamps and quota samples; it does not yet segment the estimate by model, reasoning, token composition, project, or subagent. That makes a single `runtime/week` number sensitive to workload changes. + +For a power-user roadmap, adopt the fork in separable slices rather than treating the expanded menu view as the final architecture: + +1. app-server connection, complete rate-limit/reset model, and data-validation fixes; +2. reset UI, exact countdowns, and Reset Guard notifications; +3. activity ingestion and an incremental analytics store; +4. runtime estimate with confidence and segmentation; +5. advanced charts and settings. + +## Product architecture: two ledgers plus reconciliation + +The strongest differentiator is to combine two independent ledgers: + +1. **Allowance ledger:** event-aware snapshots from `account/rateLimits/read` and `account/rateLimits/updated`, plus reset-credit snapshots and reset events. +2. **Activity ledger:** incrementally parsed local turns/tasks with model, reasoning, speed, token composition, project, parent/child relationship, runtime, compactions, and selected tool metadata. + +A reconciliation engine can then align each observed quota delta with activity in the same interval: + +- allocate the delta among non-overlapping local tasks using official credit weights as a prior; +- show concurrent tasks as shared attribution rather than false precision; +- preserve an `Unattributed` bucket for Work/cloud/voice, missing logs, or delayed backend accounting; +- calibrate model/task estimates against the user’s own observed quota changes; +- break all calculations at scheduled, banked, hard, or unknown reset events. + +This creates a useful power-user answer without claiming access to OpenAI’s private ledger: “The account meter fell 12 points; 9 points are strongly associated with these two local tasks, while 3 remain unattributed.” + +The app server already supports sparse rate-limit update notifications, so the persistent connection introduced by the fork can record changes closer to the work that caused them and use periodic full reads only for reconciliation. For local logs, a production analytics engine should index appended JSONL incrementally rather than rereading whole session files on every refresh; very large or event-amplified sessions are a known real-world case. + +## Recommended hierarchy + +### MVP: broadly useful, low interpretive risk + +1. Ship banked-reset count, exact oldest-expiry time, countdown, and configurable Reset Guard notifications. +2. Show explicit estimated exhaustion time and “days/hours before reset.” +3. Make charts reset-event aware and annotate detected hard/manual/scheduled resets. +4. Compare current burn with the previous comparable window and personal median. +5. Add a conservative spike flag based on the user’s own baseline. +6. Show freshness, source, and confidence for every estimate. +7. Keep `runtime/week` but label it `estimated active runtime at recent pace`. + +### Power-user v2: explain the burn + +1. Per-model and reasoning-effort token/estimated-credit breakdown. +2. Top threads/tasks with runtime, quota delta, models, and child-agent count. +3. Cache/context panel: cached and uncached input, context growth, compactions. +4. Subagent/auto-review/tool-loop share and wait/poll-only diagnostics. +5. Current versus prior windows with filters by model, effort, task, and project. +6. Exportable local evidence for reporting suspicious consumption. + +### Experiments: valuable but easy to overclaim + +1. Reset-use advisor based on expiry and forecasted exhaustion. +2. “What if I switch to Terra/Luna or lower reasoning?” based only on the user’s comparable history. +3. User-confirmed task outcomes and `estimated credits per completed task`. +4. Inferred effective-cap change across windows. +5. Project/client budgeting and weekly allocation. + +## Interpretation and implementation risks + +- **Local tokens are not subscription debits.** Local rollouts can contain copied history, duplicated cumulative counters, or records not charged as users assume. Issue [#35259](https://github.com/openai/codex/issues/35259) explicitly separates raw local volume from OpenAI’s private ledger. +- **Off-device usage creates unexplained quota changes.** ChatGPT Work and supported agentic features share the pool; local JSONL cannot fully attribute them. +- **Reset discontinuities corrupt naïve forecasts.** Hard resets, manual banked resets, and scheduled resets must split time series. +- **Model comparisons are confounded.** Harder tasks tend to use stronger models and reasoning, so observational averages are not causal. +- **Runtime is not entitlement.** Long idle waits can consume very little or tool polling can consume a lot; time alone is not a stable denominator. +- **Cached tokens are not free, but high cache use is not automatically waste.** Current rate cards discount cached input substantially. +- **Task boundaries are fuzzy.** A thread can contain multiple tasks; clones/subagents can copy history. Prefer explicit boundaries when available and show attribution confidence. +- **Anomaly detection needs enough personal history.** Use a neutral “insufficient baseline” state instead of global thresholds. +- **Privacy is part of the product promise.** Thread/project names and local paths are more sensitive than aggregate percentages. Keep analysis local and make exports aggregate-first. + +## Bottom line + +The research supports the fork’s reset fields immediately. Beyond those, the highest-value addition is not another chart by itself but a layered explanation: + +1. **Runway:** when will I run out? +2. **Change:** is this window abnormal for me? +3. **Attribution:** which model/task/agent behavior drove it? +4. **Action:** slow down, switch model, split the task, use a reset, or wait? + +That hierarchy keeps the default menu simple while giving power users a path from a surprising percentage drop to auditable local evidence. diff --git a/docs/research/codex-limits-validation-2026-07-27.md b/docs/research/codex-limits-validation-2026-07-27.md new file mode 100644 index 0000000..d834cb4 --- /dev/null +++ b/docs/research/codex-limits-validation-2026-07-27.md @@ -0,0 +1,667 @@ +# Codex limits after GPT‑5.6: validation, counterevidence, and shipping decisions + +Date: 2026-07-27\ +Observation window: 2026-07-09–2026-07-27\ +Companion to: [codex-limits-user-research-2026-07-27.md](./codex-limits-user-research-2026-07-27.md)\ +Product: `codex-limits` + +## Executive verdict + +The validation pass supports a focused product, not a general-purpose token dashboard. + +The strongest opportunities are: + +1. **Prevent a banked reset from expiring unnoticed.** +2. **Say whether the observed allowance is likely to last until its reset.** +3. **Keep history and forecasts correct across scheduled, banked, and unexplained reset events.** +4. **Explain which numbers are backend facts, local observations, or estimates.** + +The strongest diagnostic opportunity is a later, explicitly local view of model, reasoning, thread, subagent, context, cache, compaction, and tool-loop activity. It addresses real user questions, but local rollout tokens cannot be presented as an explanation of OpenAI's subscription ledger. + +| Idea | Confidence | Decision | Short rationale | +|---|---:|---|---| +| Banked-reset count and exact next known expiry | High | **Ship** | Repeated losses and confusion; count is authoritative and returned expiry rows are backend facts. | +| One opt-in expiry reminder | High | **Ship** | Clear user harm, technically simple local notification, consistent with Apple guidance when sparse and permissioned. | +| Time-to-empty / will-it-last forecast | High | **Ship** | Users repeatedly ask this decision; backend percentage history supports an estimate if confidence is visible. | +| `runtime/week` | Medium | **Experiment** | Useful vocabulary, but runtime varies radically by model, reasoning, context, tools, and task shape. | +| Weekly suggested pace | Medium-high | **Ship with caveat** | Actionable if expressed as percentage/day or budget/day, not as an official entitlement. | +| Reset-aware history | High | **Ship** | Required for forecast correctness; reset discontinuities are common enough to mislead ordinary charts. | +| Anomaly detection | Medium | **Experiment** | Real spikes exist, but shared usage, sparse snapshots, and unknown resets create false positives. | +| Model/reasoning/thread/subagent attribution | Medium | **Experiment** | Strong demand; local observability is good, but quota attribution remains approximate. | +| Cache/context/compaction/tool-loop diagnostics | Medium-high | **Experiment** | Multiple concrete failures; useful as local diagnostics, never as proof of backend charging. | +| Reset-use advisor | Low-medium | **Experiment later** | Advice depends on undocumented reset scope, ordering, and reset-anchor behavior. | +| Automatic reset redemption | High confidence against default | **Drop as a default** | Consequential account mutation; both unwanted-consumption and accidental-expiry reports exist. | +| Confidence, source, and freshness UI | High | **Ship** | Necessary to prevent estimates from being mistaken for OpenAI facts. | +| Local-first privacy safeguards | High | **Ship as a constraint** | Rollouts can contain full prompts, outputs, paths, and tool data even though direct privacy-feature demand is weak. | + +## Method + +### Source rules + +This pass used: + +- official OpenAI announcements, help pages, pricing, and the public `openai/codex` app-server protocol; +- direct user reports in `openai/codex` GitHub issues; +- direct Reddit posts and comments in the observation window; +- official Apple notification documentation and Human Interface Guidelines. + +X was searched, but individual posts were inconsistently indexable and often lacked stable context. X sentiment is therefore not counted in the frequency signals below. Reddit scores and comments are dynamic and self-selected; they indicate resonance, not population prevalence. + +The report does not treat: + +- one user's local token count as OpenAI billing data; +- a subreddit complaint count as a representative survey; +- a model runtime as an entitlement; +- an unexplained percentage jump as proof that OpenAI changed limits. + +### Confidence rubric + +**High** + +- the problem appears in at least three independent direct reports or in multiple reports plus official product behavior; +- the required input is available through a supported API or stable local record; +- major counterexamples change presentation or safeguards, not the core need. + +**Medium** + +- the problem is repeated and plausible, but the metric depends on local heuristics, incomplete logs, or incomparable workloads; +- or the need is strong while implementation can only approximate the answer. + +**Low** + +- evidence is isolated, indirect, or dominated by product speculation; +- or the product would need undocumented semantics to make a reliable recommendation. + +### Frequency signal + +“Repeated” means multiple independent threads or issues during the window. It does not mean a measured percentage of Codex users. High Reddit engagement is recorded only as evidence that a topic resonated with that community. + +## What can actually be observed + +### Supported backend facts + +The official [Codex app-server account API](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api) exposes: + +- `account/rateLimits/read`; +- primary and secondary `usedPercent`, `windowDurationMins`, and `resetsAt`; +- backend-classified reached-limit state when present; +- `rateLimitResetCredits.availableCount`; +- reset-credit `id`, type, status, grant time, expiry time, title, and description when the backend returns detail rows; +- `account/usage/read` with an account token-activity summary and daily buckets. + +Important constraints from the same protocol: + +- rate-limit update notifications are sparse and must be merged with a full snapshot; +- reset-credit detail is snapshot-only; +- reset-credit rows may be capped, while `availableCount` is authoritative; +- a count can be available when individual expiry rows are not; +- the supported API can consume a reset, but that is an account mutation requiring explicit authorization and post-action verification. + +These fields are sufficient for exact count, exact known expiry, live quota percentage, reset timestamp, backend daily activity, and a local forecast over observed percentage history. + +The distinction between count and detail matters. `availableCount` is authoritative, but `credits` is optional and may be capped. The minimum `expiresAt` in the returned rows is therefore the **next known expiry**, not necessarily the next expiry across every available reset. The UI can say `Next reset expires` only when detail coverage is complete. Otherwise it should say `Next known expiry` and disclose `Details available for 2 of 3 resets`, or `Expiry unavailable` when no row is returned. + +### Supported local activity facts and JSONL fallback + +The supported app-server exposes more local diagnostic structure than the first research pass assumed: + +- `thread/list` returns stored threads and can expose a known parent thread; +- experimental `parentThreadId` and `ancestorThreadId` filters return spawned descendants, but omit Review and Guardian threads; +- `thread/read`, `thread/turns/list`, and `thread/items/list` expose stored thread, turn, and item structure; +- `thread/tokenUsage/updated` identifies the thread and turn and reports cumulative and last-call input, cached input, cache-write input, output, reasoning-output, and total token counts; +- item records can represent command, MCP, dynamic-tool, collaboration, subagent, sleep, and compaction activity. + +The official [app-server API overview](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#api-overview) and [v2 thread protocol](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/src/protocol/v2/thread.rs) are the preferred read-only metadata surface. They do not by themselves prove that a separate app-server connection receives live notifications from Tasks owned by another Codex process. Raw rollout JSONL is therefore a candidate incremental source for token, timing, model, and tool facts because persisted thread items are documented as lossy, historical model and reasoning settings are not complete in the ordinary `Thread` and `Turn` projections, and older or ephemeral activity may be absent. Issue #25 must prove the safe source boundary before implementation. + +Rollout JSONL can additionally contain: + +- `session_meta`: session ID, timestamp, working directory, originator, CLI version, source, and model provider; +- `turn_context`: effective model and reasoning effort; +- `token_count`: input, cached input, output, reasoning output, total tokens, and model context window; +- task, compaction, tool-call, and tool-result events; +- parent/child session relationships that can support subagent grouping in some modes. + +Sources include [discussion #12668](https://github.com/openai/codex/discussions/12668), [issue #34370](https://github.com/openai/codex/issues/34370), [issue #34061](https://github.com/openai/codex/issues/34061), and [issue #35259](https://github.com/openai/codex/issues/35259). + +These sources can support local activity receipts and diagnostics when the spike proves their read-only behavior and Coverage. `thread/tokenUsage/updated` is cumulative and can be replayed, so a collector must deduplicate and calculate deltas. Neither it nor rollout JSONL is a subscription-billing API; both may be incomplete, deleted, created on another machine, or disconnected from shared account movement. + +### Not publicly observable + +No public source provides: + +- the numeric weekly entitlement for every account and plan; +- a stable formula converting percentage into runtime; +- a one-to-one mapping from local rollout tokens to included-subscription usage; +- complete attribution for activity from other devices or shared agentic surfaces; +- a reliable cause for every hard reset or percentage discontinuity; +- the business value or correctness of a completed task. + +Any feature that implies those answers should be dropped or explicitly labeled as an estimate. + +## Repository and fork audit + +This validation also inspected the current `thrr87/codex-limits` implementation and `shiptomorrow/main`, not only the feature screenshots. + +### What the fork gets right + +- A persistent app-server connection is the correct prerequisite for lower-latency reads and future notification handling. +- `availableCount` is parsed as the banked-reset count. +- The minimum returned expiry and a runtime-at-recent-pace estimate are useful prototypes. +- Reset-time tolerance and implausible-percentage-jump validation address real sampling problems. +- The new estimator and validation logic have focused unit tests. + +### Gaps that must be corrected before treating the fork as product-complete + +1. **Expiry coverage is lost.** The fork stores only `availableCount` and one minimum returned date. Because the backend may cap `credits`, it cannot distinguish a complete list from partial detail and must not label that date as the globally oldest reset. +2. **Notifications are not implemented.** The persistent reader currently accepts responses with an `id` and ignores notifications such as `account/rateLimits/updated`. A full snapshot is still required for reset-credit detail because the rolling update contains only `rateLimits`. +3. **The reminder does not exist yet.** There is no `UNUserNotificationCenter` integration, permission flow, pending-request replacement, or cancellation when a reset is used. +4. **`runtime/week` has no workload segmentation.** The fork derives active intervals from `task_started`, `task_complete`, and `token_count`, but does not segment by effective model, reasoning, subagent tree, context, or compaction. The number is a recent-mix estimate, not a weekly entitlement. +5. **Local activity and account allowance are not reconciled.** The fork does not yet show exact local activity beside the observed account-percentage delta and an explicit unattributed remainder. + +### Existing chart integrity issue + +The current [`BurnDownChart`](../../Sources/CodexLimits/MenuContentView.swift) reconstructs the period before the first allowance sample from daily token buckets, merges those reconstructed points with real samples, and renders the entire series as `Actual`. That violates the proposed provenance contract: a token-weighted estimate is visually presented as an observed account fact. + +Before adding analytics, either remove this backfill or render it as a separately labeled estimated segment. Exact allowance samples, local activity, and projections must never share the same visual style or legend label. + +## UI and information-architecture validation + +The current popover is 420 points wide and already contains a headline, forecast copy, a 190-point four-series chart, reset and pace data, other limits, freshness, and controls. Adding reset inventory, reminders, runtime history, task trees, model mix, cache/context diagnostics, and provenance to that fixed layout would reduce scanability and make source distinctions harder to understand. The menu-bar entry should therefore open a larger, scrollable Analytics Workspace instead of cramming more rows into the existing fixed panel. + +This is supported by three external constraints: + +- An OpenAI collaborator reports that a Codex progress bar was reverted after negative feedback because users confused `used` with `remaining` and the bar consumed substantial space. The same comment says a gauge is inappropriate for absolute token counts. [GitHub #21324](https://github.com/openai/codex/issues/21324). +- Apple’s [Charts guidance](https://developer.apple.com/design/human-interface-guidelines/charts) calls for a clear main message, visual hierarchy, compact-width restraint, and distinctions that do not depend only on color. +- Apple’s [macOS guidance](https://developer.apple.com/design/human-interface-guidelines/designing-for-macos) supports using a resizable window for richer information, while `MenuBarExtra` remains suitable for glanceable status and immediate decisions. [MenuBarExtra](https://developer.apple.com/documentation/swiftui/menubarextra). + +The accepted information architecture is therefore one workspace with a clear reading order: + +### Current guidance + +- remaining allowance with an explicit `remaining` orientation; +- scheduled reset and runway verdict; +- banked-reset count, next known expiry, detail coverage, and reminder state; +- suggested percentage/day; +- source freshness and one compact confidence disclosure; +- one primary allowance visual at most. + +### Graphs, facts, receipts, and insights + +- a compact current-state header above switchable `Graphs`, `Facts`, and `Insights` views; +- `Graphs` switches between Usage remaining, Token activity, Usage per token, and Concurrency while preserving the time range and source-supported filters; +- `Facts` holds account facts, banked resets, Other limits, and Usage Receipts; +- reset-aware allowance history with factual and estimated segments; +- per-thread and agent-tree local activity receipts; +- model and reasoning mix when coverage permits; +- cache, context, compaction, wait/poll, and tool-loop diagnostics; +- observed account delta, associated local activity, and an `unattributed` remainder shown as different quantities; +- filters, point inspection, accessible table equivalents, and export. + +No color palette or final chart form needs to be locked before a prototype. Two visual rules do need to be locked: use one explicit `remaining` orientation for allowance percentages, and never use a token-count gauge. + +## Evidence matrix + +| Product question | Direct problem evidence in window | Frequency signal | Technical observability | Primary UX risk | Confidence | Decision | +|---|---|---|---|---|---:|---| +| When does my next reset expire? | At least five independent Reddit threads plus GitHub #32540 report confusion or loss. | Repeated; one loss report reached roughly 30+ votes. | Exact `expiresAt` for returned rows; count remains available without complete details. | Presenting the earliest returned row as globally earliest when detail is capped. | High | **Ship** | +| Should the app remind me? | Users describe calendar reminders, babysitting expiry, and third-party safety-net tools. | Repeated but episodic. | Local notification from a known timestamp. | Notification fatigue, permission denial, stale schedules. | High | **Ship, opt-in** | +| Will my allowance last? | Multiple users translate usage into hours, days, one goal, or one feature. | Very strong topic resonance; dedicated megathread. | Estimate from percentage samples and reset time. | False certainty under workload changes or shared usage. | High | **Ship with confidence** | +| How many hours do I get per week? | Users ask and report measured runtime. | Repeated, but answers vary by more than an order of magnitude. | Only a local pace estimate. | Looks like an official entitlement. | Medium | **Experiment; rename** | +| What happened around a reset? | Reports of partial jumps, missing windows, changed reset timestamps, and ambiguous hard resets. | Repeated. | Detect discontinuities and known reset events; cause may remain unknown. | Misclassifying backend corrections or shared activity. | High | **Ship event-aware history** | +| Was this burn abnormal? | Sudden large drops and unexpectedly short windows are repeatedly reported. | Repeated. | Compare with the user's own prior comparable windows. | False accusations and alert fatigue. | Medium | **Experiment** | +| Which model/task/subagent used it? | Users explicitly compare Sol/Terra, effort levels, goals, reviews, and subagent fan-out. | Repeated and specific. | Strong local activity attribution; weak backend quota attribution. | Confusing correlation with billing or quality. | Medium | **Experiment** | +| Was context/cache/tool looping involved? | Direct analyses and GitHub issues show replay, compaction loops, polling, and event amplification. | Multiple technically detailed cases. | Rich local signals; no exact quota mapping. | Calling cached input “waste” or overstating causality. | Medium-high | **Experiment** | +| Should I use a reset now? | Users intentionally burn allowance before expiry and ask whether resets auto-apply. | Repeated. | Inputs are partly observable; reset semantics are incomplete. | Bad advice can waste remaining allowance or a reset. | Low-medium | **Experiment later** | +| Can I trust this number? | Users report disagreement between app, CLI, web, and reset states. | Repeated. | Source and fetch time are known; completeness can be modeled. | Too much technical clutter in a small menu. | High | **Ship** | +| Is deep local analysis private? | Little direct demand in-window; one retention question. | Weak expressed demand. | Risk is directly visible in rollout contents. | Exposing prompts, paths, tool output, or secrets. | High as safeguard | **Ship constraint** | + +## 1. Reset expiry and reminders + +### Problem evidence + +The evidence is unusually direct: + +- [“Are expiring resets automatically get used?”](https://www.reddit.com/r/codex/comments/1uyqhir/are_expiring_resets_automatically_get_used/) includes users who lost resets, assumed auto-use, or redeemed early because the time was unclear. +- [“Anyone know if when a reset expires is it used automatically?”](https://www.reddit.com/r/codex/comments/1v7eb2i/anyone_know_if_when_a_reset_expires_is_it_used/) asks whether the user must “babysit” a reset and includes another same-day loss. +- [“Lost a banked reset because the expiration timing is so unclear”](https://www.reddit.com/r/codex/comments/1v7s7hi/lost_a_banked_reset_because_the_expiration_timing/) reports a reset disappearing during the displayed date and raises timezone ambiguity. +- [“Banked reset and due date”](https://www.reddit.com/r/codex/comments/1v7r85x/banked_reset_and_due_date/) describes a European user planning around “today” and finding the reset already gone in the morning. +- [“Banked reset safety net”](https://www.reddit.com/r/codex/comments/1v7hyku/banked_reset_safety_net/) exists specifically to plan expiries and redeem before loss. +- [GitHub #32540](https://github.com/openai/codex/issues/32540) asks the official app to show a full timestamp, timezone, and countdown instead of only `Expires 7/12`. +- The user-provided X screenshot independently reports losing a banked reset because it expired before the user could claim it. It corroborates the problem but is not counted as a separate stable frequency signal because the original post was not reliably indexable. + +Official OpenAI documentation says banked resets generally expire 30 days after grant. The supported app-server now exposes individual expiry timestamps when the backend provides them. [OpenAI promotion terms](https://help.openai.com/en/articles/20001271-codex-referral-promotions) and [app-server API](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#account-api). + +### Disconfirming evidence + +- The official Codex CLI `/usage` flow already shows exact local expiry times. Several Reddit replies correctly point users to it. +- The need is episodic: it matters only when a user has a banked reset with known detail. +- The backend may return only a count or a capped subset, so an app cannot always name the globally next expiry. +- A notification is best-effort and can be hidden by Focus or user settings. + +These points reduce the need for a complex subsystem, not for the feature itself. A menu-bar app is valuable precisely because it can make a supported but buried timestamp continuously visible. + +### Apple UX validation + +Apple's [notification HIG](https://developer.apple.com/design/human-interface-guidelines/notifications) and [authorization guidance](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications) support this use case with constraints: + +- request permission only after the user turns on the reminder; +- default to one useful notification for the next known expiry, not repeated nudges; +- replace or cancel the pending request when the reset is used or its timestamp changes; +- do not promise exact delivery; +- keep the in-app expiry visible even when notifications are denied; +- avoid `Time Sensitive` for reminders sent days or many hours early; +- do not include account IDs, project names, tokens, or other sensitive data in the preview. + +A known date can use a local calendar notification even when the app is not running. It does not justify a hidden persistent helper. [Scheduling local notifications](https://developer.apple.com/documentation/usernotifications/scheduling-a-notification-locally-from-your-app) and [macOS background-process guidance](https://developer.apple.com/documentation/appkit/managing-ongoing-background-processes-in-your-mac). + +### Recommendation + +**Ship**: + +- count; +- exact local date/time and countdown for the next known expiry; +- a visible partial-detail state when count exceeds returned rows; +- one opt-in local reminder; +- a user-selected threshold such as 24 hours, 3 days, or 7 days; +- rescheduling after every fresh reset snapshot; +- a neutral message such as `1 banked reset expires in 23 hours`. + +Do not ship repeated automatic reminders or `Time Sensitive` by default. + +## 2. Runway, weekly pace, and `runtime/week` + +### Problem evidence + +Users ask in work units rather than percentages: + +- one Plus user reports [100% of weekly Ultra usage in 1h32m](https://www.reddit.com/r/codex/comments/1uv7ui6/codex_56_sol_at_ultra_used_100_of_my_weekly_usage/); +- another reports [one `/goal` consuming the week in about 4.5 hours](https://www.reddit.com/r/codex/comments/1v6wvg9/did_codex_just_eat_my_entire_weekly_quota_on_one/); +- [a Plus workflow question](https://www.reddit.com/r/codex/comments/1v1dgqh/any_tips_for_making_codex_weekly_usage_last/) asks how to make the allowance last after it began disappearing in one or two days; +- the [usage-limits megathread](https://www.reddit.com/r/codex/comments/1v42x6r/codex_usage_limits_and_performance_megathread/) aggregates many similar reports; +- one user manually documents [runtime by plan and model](https://www.reddit.com/r/codex/comments/1v1mr6k/what_i_actually_got_from_chatgpt_plus/), showing demand for a practical unit. + +### Disconfirming evidence + +Runtime is not stable: + +- the Ultra report's highest-voted counterpoint says Ultra is inappropriate for a Plus daily workflow, and the original author acknowledges that the run completed substantial multi-system work; +- [a 47-minute Sol XHigh read-only test](https://www.reddit.com/r/codex/comments/1v6m80n/usage_test_after_reset/) reports only about 2% weekly usage; +- [another workflow discussion](https://www.reddit.com/r/codex/comments/1v4aaim/what_is_your_codex_workflow/) includes roughly eight-hour workdays using 10–20% daily; +- answers to [“How long does $20/$100 last?”](https://www.reddit.com/r/codex/comments/1v1cge2/how_long_does_it_take_to_go_through_the_20100/) range from a single user finding $100 sufficient to a multi-worker setup spending roughly $200/day; +- [“No Five Hour Limit?”](https://www.reddit.com/r/codex/comments/1uunmgu/no_five_hour_limit/) reports only 22% consumption despite multiple concurrent Sol Ultra sessions. + +OpenAI explicitly says usage varies with model, task size, context, reasoning, tool use, retrieval, caching, and execution surface. It publishes approximate five-hour message ranges but no numeric weekly entitlement. [Current Codex pricing and limits](https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan). + +### Recommendation + +**Ship** the decision: + +- `At your observed pace, likely to last until reset`; +- estimated exhaustion date/time; +- gap between estimated exhaustion and scheduled reset; +- suggested remaining budget in percentage/day. + +**Experiment** with runtime: + +- show `Active time this week` as observed local time, counting overlapping Task Tree activity once; +- label the forecast `Estimated active time available`, not `Runtime/week`; +- use recent comparable activity only; +- show range and confidence, not a single authoritative number; +- show the forecast only when Local Coverage is high enough; +- invalidate or lower confidence after a reset, model-mix change, long sampling gap, or unexplained shared-pool movement. + +Drop any wording implying that OpenAI grants a fixed number of runtime hours. + +## 3. Reset-aware history + +### Problem evidence + +History can jump for reasons unrelated to ordinary consumption: + +- users reported a [partial 44% restoration followed by an immediate drop](https://www.reddit.com/r/codex/comments/1v4rjn0/anyone_else_suddenly_get_44_weekly_codex_usage/); +- [GitHub #34661](https://github.com/openai/codex/issues/34661) reports `/usage` appearing to trigger a reset; +- [GitHub #32840](https://github.com/openai/codex/issues/32840) reports a missing five-hour window while the weekly window remained visible; +- [GitHub #34874](https://github.com/openai/codex/issues/34874) reports a free reset changing `reset_at` without restoring allowance; +- the high-engagement [reset complaint](https://www.reddit.com/r/codex/comments/1v788rs/i_hate_the_resets_it_is_unpredictable_when_they/) explains how a global reset can arrive when a user still has most of the allowance left. + +### Disconfirming evidence + +- A jump can be a display refresh, backend correction, scheduled window, banked reset, promotional reset, or an unknown event. +- Sparse app-server updates can omit fields. +- Sampling alone cannot reliably identify the cause. + +### Recommendation + +**Ship** event-aware history because it is a correctness requirement: + +- split forecast segments at known reset boundaries; +- annotate `scheduled reset`, `banked reset consumed`, and `detected discontinuity`; +- use `unknown reset or correction` when causality is not observable; +- retain the pre-reset sample so the chart does not reinterpret replenishment as negative consumption; +- recompute confidence after a discontinuity. + +Do not name an event `OpenAI hard reset` unless its source is known. + +## 4. Anomaly detection + +### Problem evidence + +The window contains repeated sudden-depletion reports: + +- [less than ten minutes to a five-hour limit](https://www.reddit.com/r/codex/comments/1ut9wpk/gpt_56_is_unusable/); +- [a first post-reset reading already at 79% used](https://github.com/openai/codex/issues/32607); +- [usage errors with no visible intervening activity](https://www.reddit.com/r/codex/comments/1ut14h3/anyone_else_having_usage_errors/); +- one Ultra task and one `/goal` consuming entire weekly allowances. + +### Disconfirming evidence + +- Shared usage can happen on another device or supported agentic surface. +- A sparse sample can make a gradual burn look instantaneous. +- Workloads and reasoning levels are not comparable by default. +- User reports cannot prove a universal entitlement reduction. + +### Recommendation + +**Experiment** with personal-baseline anomalies: + +- compare only against the user's prior windows; +- require a minimum sample density; +- segment by model/reasoning mix when available; +- report `usage increased faster than your baseline`; +- show the observed delta, interval, source, and missing-data caveat; +- let users dismiss or mark an event as expected. + +Keep the result passive inside `Insights` and show its evidence and Confidence. Do not send anomaly notifications. Never label a deviation `billing error` or `limit reduction`. + +### Accepted comparable-workload design + +The product can calculate three distinct quantities: + +1. `Token Activity` — an account count from bounded lifetime-token readings when possible, a visibly partial daily fact when not, or a local count from Task records. +2. `Allowance Intensity` — observed allowance percentage points per unit of Token Activity for a bounded workload mix. +3. `Equivalent Capacity` — the Token Activity that would correspond to 100% of allowance if the observed mix and intensity remained constant. + +Equivalent Capacity is not a published token entitlement. It is a personal extrapolation whose validity depends on model, reasoning, cache, context, tools, concurrency, task mix, shared activity, and reset continuity. + +The default `Reference Baseline` is the median of exactly four previous complete High-comparability weekly windows. A user may pin another qualifying historical period. Incomplete coverage does not automatically suppress factual metrics: + +- direct Token Activity remains factual; +- Allowance Intensity can use any bounded interval with usable start and end allowance readings; +- Equivalent Capacity can be shown with reduced confidence and the observed interval; +- a segment must stop at a known reset or detected discontinuity; +- a gap that may contain an unknown reset or correction cannot be bridged into one estimate. + +The chart should show current comparable workload cost as a multiplier against baseline, expose raw token activity and allowance movement in the tooltip, and avoid the claim that OpenAI changed the limit. + +## 5. Model, reasoning, task, and subagent attribution + +### Problem evidence + +Users explicitly want to choose: + +- Sol versus Terra or Luna; +- Medium, High, XHigh, or Ultra; +- one long goal versus smaller tasks; +- one thread versus parallel worktrees; +- foreground work versus subagent fan-out. + +The clearest request is [“GPT‑5.6 may have the same pricing but use more per task”](https://www.reddit.com/r/codex/comments/1v5norf/gpt56_in_codex_may_have_the_same_token_pricing/), which asks for model, input, output, reasoning, tool, agent, effort, and cost breakdowns. [Issue #34370](https://github.com/openai/codex/issues/34370) shows why effective metadata matters: a child requested at Medium could record an effective High `turn_context`. + +### Disconfirming evidence + +- Task boundaries are fuzzy inside a long thread. +- A child may inherit context and create duplicated local traffic without an equivalent independent backend debit. +- Fallback local records may expose only a working directory. They must not be used to invent a second hierarchy when Codex project grouping is unavailable. +- Off-device and shared-pool activity cannot be assigned to local tasks. +- Lower usage is not automatically better if the result is worse or incomplete. + +### Recommendation + +**Experiment** as a power-user view: + +- aggregate local activity by effective model and reasoning effort; +- preserve parent/child relationships and show subagent share; +- group Tasks under the same short folder or project name and hierarchy already presented by Codex; +- show quota percentage deltas separately from local token activity; +- attach an attribution confidence such as `complete local thread`, `partial local data`, or `shared activity possible`. + +Do not calculate a universal `efficiency score`. If outcome value is needed, ask for an optional user label such as `completed`, `partial`, or `abandoned`. + +## 6. Token, cache, context, compaction, and tool-loop diagnostics + +### Problem evidence + +- A user reports [118M local tokens and 71% weekly usage](https://www.reddit.com/r/codex/comments/1v3c19s/i_used_118m_codex_tokens_in_one_day_and_consumed/), mostly cached input. +- A separate [context-replay analysis](https://www.reddit.com/r/codex/comments/1v4vawj/important_findings_on_cache_and_baked_in_codex/) argues that repeated input dominates local traffic. +- [GitHub #35259](https://github.com/openai/codex/issues/35259) measures wait/status-only turns at 19.8% of raw local tokens in one corrected reset window. +- [GitHub #34061](https://github.com/openai/codex/issues/34061) documents event amplification and disk growth across task, token, tool, and compaction records. +- [GitHub #35226](https://github.com/openai/codex/issues/35226) documents a July 24 auto-compaction loop that reread files and consumed an estimated 10–15% of paid usage without completing the edit. +- [GitHub #35300](https://github.com/openai/codex/issues/35300) reports a prompt-cache breakpoint issue. + +### Disconfirming evidence + +- The author of #35259 explicitly says raw local tokens are not subscription usage and found no proof of a silent quota reduction. +- Cached input is materially cheaper than uncached input; a high cache ratio is not inherently bad. +- A tool-heavy task may be valuable and correctly implemented. +- A proposed [tool-batching workaround](https://www.reddit.com/r/codex/comments/1v4vcnr/possible_gpt56_sol_usage_workaround_explicit_tool/) has both reported wins and a commenter who saw no improvement under five parallel worktrees. + +### Recommendation + +**Experiment** with transparent local diagnostics: + +- cached and uncached input; +- output and reasoning output; +- context-window occupancy trend; +- compaction events; +- repeated wait/status-only turns; +- repeated file reads or similar tool calls; +- tool-call count and patch/test/fix cycles; +- parent and child session activity. + +Use labels such as `local rollout activity`, `possible repeated work`, and `correlated quota delta`. Do not use `wasted credits`, `OpenAI charged`, or `billing cause`. + +## 7. Reset-use advice and automatic redemption + +### Problem evidence + +Users want to avoid both failure modes: + +- losing an unused reset at expiry; +- consuming a reset while substantial allowance remains. + +[GitHub #32218](https://github.com/openai/codex/issues/32218) proposes a carefully bounded, opt-in queue that redeems one reset only after a real usage-limit block and notifies on success, expiry, cancellation, or failure. The proposal exists because a long-running task can otherwise stop unattended. + +The countercase is [GitHub #28525](https://github.com/openai/codex/issues/28525), where a user reports an automatic reset consumption without explicit confirmation and asks Codex to pause first. The [July 26 reset complaint](https://www.reddit.com/r/codex/comments/1v788rs/i_hate_the_resets_it_is_unpredictable_when_they/) likewise objects to resets arriving when most quota remains. + +### Unknown mechanics + +OpenAI does not publicly guarantee: + +- FIFO consumption; +- how every `Full reset` affects all limit windows; +- whether redemption changes the normal reset anchor in every account state; +- a universal optimal threshold for redemption. + +### Recommendation + +**Ship only factual guidance**: + +- expiry timestamp; +- current remaining percentage; +- scheduled reset timestamp; +- estimated likelihood of exhaustion before expiry. + +**Experiment later** with a read-only advisor using explicit assumptions: + +- `At your recent pace, you are unlikely to hit the limit before this reset expires`; +- `Using it now would replace approximately 12% remaining`. + +**Drop automatic redemption.** The accepted product uses a Reset Reminder and leaves redemption to the user. + +## 8. Confidence, provenance, and freshness + +### Problem evidence + +Users report conflicting or missing usage states between app, CLI, web, and refreshes. The underlying data genuinely comes from different layers: + +1. backend rate-limit snapshots; +2. backend daily usage buckets; +3. local rollouts; +4. local estimates derived from the first three. + +The app-server protocol itself warns that update notifications are sparse and that reset detail can be missing even when the count is known. + +### Recommendation + +**Ship** a compact provenance system: + +- `Live allowance · OpenAI account`; +- `Reset expiry · OpenAI account · updated 2m ago`; +- `Task activity · local Codex logs`; +- `Runway · estimate · medium confidence`. + +Each derived metric should carry: + +- source class; +- observation/fetch time; +- completeness; +- sample count and span; +- confidence-reduction reasons. + +In the menu, this can be one secondary label and a tooltip or disclosure. It should not become a wall of telemetry. + +## 9. Privacy + +### Evidence + +Direct in-window demand is weak: one [data-retention question](https://www.reddit.com/r/codex/comments/1v3k1ax/how_long_is_my_data_retained_from_codex_if_i_use/) had little engagement. + +The technical risk is nevertheless high. Official guidance to inspect `~/.codex/sessions` shows that rollout files can contain full instructions, prompts, outputs, working directories, and tool records. [Discussion #12668](https://github.com/openai/codex/discussions/12668). Deep analytics therefore process potentially sensitive source paths, code, terminal output, and secrets even if the product never uploads them. + +### Recommendation + +Treat privacy as a shipping constraint, not a marketing-only feature: + +- local processing by default; +- read only known Codex data locations; +- aggregate numeric/event fields without storing raw prompt or tool text; +- hash or omit project paths in persisted analytics; +- make content-level loop inspection opt-in; +- expose retention and deletion controls; +- never put project names, account identifiers, token details, or activity content in notifications; +- make export explicit and redactable. + +The product should remain useful using backend snapshots alone. Deep local diagnostics can be an optional mode. + +## Disconfirming evidence summary + +The research does **not** validate these claims: + +- “GPT‑5.6 always consumes the weekly allowance faster.” +- “OpenAI silently reduced every user's weekly cap.” +- “Local JSONL tokens equal billed or included usage.” +- “Cached tokens are wasted tokens.” +- “Ultra is inefficient because it consumed more quota.” +- “Runtime per week is a stable plan entitlement.” +- “Every percentage jump is a hard reset.” +- “Automatic reset redemption is always user-beneficial.” +- “More tool calls or subagents necessarily means a worse outcome.” + +The product should help users test these hypotheses against their own history without presenting them as established facts. + +## Recommended product sequence + +### Ship now + +1. Banked-reset count. +2. Exact next known expiry with timezone, live countdown, and detail coverage. +3. One opt-in local expiry reminder. +4. Suggested percentage/day and will-it-last forecast. +5. Reset-aware history and forecast segmentation. +6. Source, freshness, completeness, and confidence labels. +7. Local-first privacy defaults. + +### Experiment next + +1. Active time this week and estimated active time available at recent pace, with a range. +2. Comparable workload cost and Equivalent Capacity over time. +3. Personal-baseline anomaly detection. +4. Local model/reasoning/thread/subagent breakdown. +5. Cache, context, compaction, and tool-loop diagnostics. +6. Read-only reset-use scenarios. + +### Drop or defer + +1. A fixed `hours per week` entitlement. +2. A universal usage-efficiency score. +3. Claims that local activity explains backend billing. +4. Automatic reset redemption. +5. Multiple unsolicited expiry notifications. +6. `Time Sensitive` notifications days before expiry. +7. A hidden always-running helper solely for a known expiry timestamp. + +## Decision queue before PRD + +These questions should be resolved one at a time. The recommended defaults preserve the high-confidence product while keeping uncertain analytics reversible. + +1. **Product center — resolved:** how should glanceable decisions and power analytics be divided?\ + **Decision:** opening the menu-bar item presents one screen-aware, scrollable Analytics Workspace. A compact current-state header stays visible above switchable `Graphs`, `Facts`, and `Insights` views. `Graphs` switches between `Usage remaining`, `Token activity`, `Usage per token`, and `Concurrency`; `Facts` holds account facts, reset details, Other limits, and Usage Receipts. +2. **Hours-based metrics — resolved:** what should measured and forecast time mean?\ + **Decision:** `Active time this week` is observed local time in the current Allowance Window, with overlapping Task Tree activity counted once. `Estimated active time available` is a range based on recent comparable work and appears only with high enough Local Coverage. Never use `runtime/week` or imply a fixed hours entitlement. +3. **Reminder default — resolved:** what should happen when a reset first becomes observable?\ + **Decision:** reminders remain off until enabled. Once enabled, schedule one notification with a default Reminder Lead Time of 24 hours; the user can select another interval. +4. **Missing expiry detail — resolved:** how visible should incomplete backend detail be?\ + **Decision:** show a short Reset Detail Coverage label next to the authoritative count, such as `3 banked resets · 1 expiry known`. Use `Next known expiry` for partial detail and `Expiry dates unavailable` when no expiry is known. +5. **Local diagnostics — resolved:** what is the consent boundary?\ + **Decision:** Codex information already accessible on the machine can be analyzed without a separate analytics opt-in, but all Codex-derived data, computation, and derived history remain local and are not transmitted as product telemetry. +6. **Project identity — resolved:** how should work be grouped and named?\ + **Decision:** reuse the hierarchy already presented by Codex and show its short folder or project name. Do not create aliases, infer another hierarchy, or rename Codex projects. +7. **Mutation boundary — resolved:** may the app change Codex state?\ + **Decision:** no. It reads, calculates, shows, reminds, and analyzes on request without redeeming resets, changing settings, or controlling tasks. `Reset Reminder` replaces the rejected Reset Automation and never changes account state. +8. **Usage deviation posture — resolved:** should unusual burn trigger notifications?\ + **Decision:** no. Show a Usage Deviation as a passive Insight with its evidence, comparison period, Coverage, and Confidence. Do not send anomaly notifications or claim a cause. +9. **Canonical orientation — resolved:** should allowance be shown as used or remaining?\ + **Decision:** use Codex’s reader-facing label `Usage remaining` for every primary percentage and burn-down orientation. Consumption metrics may use `used` only when the label names the quantity explicitly. +10. **Test seam — resolved:** what unit should own the intelligence logic?\ + **Decision:** one pure `UsageIntelligenceEngine` transforms normalized account events, local activity events, settings, and `now` into the complete reader-facing snapshot. Source adapters only read and normalize data. SwiftUI only renders the snapshot. Test adapters against protocol fixtures and test forecasts, reset segmentation, coverage, reconciliation, confidence, and copy-driving states through the engine. +11. **Codex-assisted trigger — resolved:** may model-assisted analysis run automatically?\ + **Decision:** no. It is a separately labeled, user-initiated `Analyze with Codex` action with an information tip explaining that it sends a request to Codex and consumes allowance. +12. **Codex-assisted preflight — resolved:** when is an additional confirmation required?\ + **Decision:** Metadata-only Analysis starts directly from the explicit action. Source-backed Analysis first shows a short preflight listing the content categories that will be sent to Codex. +13. **Codex-assisted execution profile — resolved:** which model performs the default analysis?\ + **Decision:** show the feature only when `model/list` advertises the exact GPT-5.6 Luna Medium profile. Do not fall back to GPT-5.5 Medium, Terra, Sol, another reasoning level, or the analyzed Task model. A stronger retry requires a separate user action and an explicitly available profile. +14. **Comparable workload baseline — resolved:** what historical period anchors the comparison?\ + **Decision:** the median of exactly four previous complete High-comparability weekly windows, with the option to pin another qualifying historical period. Partial observations remain factual but enter comparison only when the Coverage, comparability, boundary, and workload-mix gates in `docs/MEASUREMENT-CONTRACT.md` pass. +15. **Canonical token totals — resolved:** which token source leads the weekly view?\ + **Decision:** Account Token Activity is the primary weekly total. Prefer a same-account lifetime-token delta across a bounded weekly interval. Calendar-day buckets remain factual, but a partial-day sum never becomes an exact weekly total. Local Token Activity provides Task, agent, and model breakdowns. When compatible interval and token definitions are proven, the product shows both and reports Local Coverage rather than silently merging them. +16. **Product language — resolved:** how should reader-facing copy be written?\ + **Decision:** follow Orwell’s six rules: use literal, short, necessary, active, everyday language and break a rule only to avoid harsh, false, or unclear text. Product-specific examples live in `docs/PRODUCT-LANGUAGE.md`. +17. **Usage receipt unit — resolved:** what defines one receipt?\ + **Decision:** one Task Tree: the root Codex Task and every observable descendant agent task. The receipt totals the tree and drills down to agents and turns; projects group Tasks but do not define receipt boundaries. +18. **Analytics history retention — resolved:** how long should Derived Records remain on the machine?\ + **Decision:** keep Analytics History without a time limit until the user deletes it. Do not copy Source Content into the history. `Delete analytics history` removes the whole Codex Limits history on the Mac and in the selected sync folder. It does not rebuild automatically; a separate explicit action can rebuild only what remains available from Codex sources. +19. **PRD scope — resolved:** should advanced analytics be deferred to a later product phase?\ + **Decision:** no. The PRD covers the complete Analytics Workspace, including comparable-workload analysis, Active Time, Concurrency, deep local diagnostics, and user-initiated Codex-assisted Insights alongside the high-confidence allowance and reset features. +20. **Primary allowance window — resolved:** which account window anchors the product?\ + **Decision:** the `10080`-minute weekly window owns the menu-bar percentage, header, Runway, Suggested Pace, and default Usage remaining graph. Five-hour and model-specific windows stay named Other limits and never replace a missing weekly window. +21. **Measurement contract — resolved:** when may the product show Coverage, Confidence, or a comparison?\ + **Decision:** use the shared thresholds, interval boundaries, workload-mix gates, account partitioning, and withholding rules in `docs/MEASUREMENT-CONTRACT.md`. Low-confidence conclusions remain hidden with a reason. +22. **Local source boundary — resolved:** may a second app-server observe another Codex process by assumption?\ + **Decision:** no. A technical spike must prove the safest read-only, incremental source before Local Token Activity implementation. The app never resumes or takes ownership of a user Task merely to observe it. +23. **Account facts — resolved:** which factual account context belongs in Token activity and Facts?\ + **Decision:** show available lifetime tokens, peak daily tokens, longest running turn, current and longest streak, credits, unlimited-credit state, and spend-control state. Missing fields do not invalidate present facts. +24. **History deletion across sync — resolved:** how does deletion stay final when another Mac is offline?\ + **Decision:** advance an empty sync generation, block older imports, and keep deletion pending if the folder is unavailable. Preserve settings and do not claim completion until supported stores are cleared. + +## Final assessment + +The fork's banked-reset count and returned-expiry fields are validated strongly enough to ship after preserving detail coverage and using `next known expiry` when the list is partial. `Suggested pace` is also defensible when expressed as a budget derived from the current remaining percentage and reset time. Hours are useful only as observed Active Time and a personal, confidence-bounded estimate of Active Time available. + +The next differentiated feature should not be another chart. It should be a trustworthy decision layer: + +- **Your next known reset expiry is at this exact local time; detail coverage is 2 of 3.** +- **Your current allowance is or is not likely to last until reset.** +- **This estimate changed because a reset, workload mix, or data gap occurred.** +- **These are OpenAI account facts; these are local diagnostics; this is an estimate.** + +That combination addresses the most repeated user pain while staying inside what the product can honestly observe. diff --git a/docs/research/codexbar-method-comparison-2026-08-21.md b/docs/research/codexbar-method-comparison-2026-08-21.md new file mode 100644 index 0000000..f3d11af --- /dev/null +++ b/docs/research/codexbar-method-comparison-2026-08-21.md @@ -0,0 +1,222 @@ +# Codex Limits a metoda multi-provider z CodexBar + +Data badania: 2026-08-21\ +Zakres: porównanie architektury, kontraktów, kolejności prób, transportu, obsługi poświadczeń, normalizacji, odświeżania i błędów. To nie jest analiza ani propozycja kopiowania kodu CodexBar. + +> Aktualizacja runtime 2026-08-22: późniejszy spike potwierdził granicę zaufania rekomendowaną w tym dokumencie, ale odrzucił dwa pierwotne źródła v1. Grok Build stable 1.0.5 zwraca `-32601 Method not found` dla `x.ai/billing` przez zewnętrzne ACP, a OpenCode `serve --pure` nie mieści się w budżecie pamięci i zapisów. Codex Limits nie przejmuje fallbacków CodexBar opartych na credentials, cookies ani prywatnych backendach, więc Grok i OpenCode są odroczone. Zobacz [wyniki walidacji](multi-integration-v1-validation-spikes-2026-08-22.md). + +## Werdykt + +Tak — warto zastosować **podobną metodę na poziomie architektury**: stała lista integracji, uporządkowane źródła danych dla każdej integracji, wspólny mały wynik znormalizowany oraz stan odświeżania przechowywany osobno dla każdego dostawcy i konta. + +Nie — nie warto odtwarzać całego systemu CodexBar ani jego konkretnych sposobów pozyskiwania danych. Szeroki zasięg CodexBar jest osiągany między innymi przez prywatne endpointy, odczyt obcych poświadczeń, cookies przeglądarki, emulowanie terminala i parsowanie ekranów CLI. To podnosi koszt utrzymania, ryzyko pomylenia konta i powierzchnię bezpieczeństwa. Codex Limits powinien zachować obecną zasadę: w pierwszej kolejności używać oficjalnego, należącego do dostawcy procesu lub API, a źródło i pewność danych pokazywać jawnie. + +Najlepszy wariant dla Codex Limits to zatem **„CodexBar-like pipeline, Codex Limits trust boundary”**: + +1. istniejący `codex app-server --stdio` pozostaje bez zmian jako adapter Codex; +2. dokładamy mały kontrakt integracji i opcjonalną listę prób źródeł; +3. fallback zachodzi wyłącznie po błędzie sklasyfikowanym jako dostępność źródła, nigdy po niejednoznaczności konta lub danych; +4. nie importujemy automatycznie cookies, tokenów ani kluczy z innych aplikacji; +5. nie budujemy teraz dynamicznego systemu pluginów ani rozbudowanego rejestru metadanych. + +## Wersja CodexBar objęta badaniem + +Repozytorium zostało sprawdzone na bieżącym `main`: + +- commit [`f74117aeb7a9ee02a78c0f08ca354ff26b2292e0`](https://github.com/steipete/CodexBar/commit/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0), z 2026-08-20; +- najnowsze wydanie: [`v0.54.0`](https://github.com/steipete/CodexBar/releases/tag/v0.54.0), opublikowane 2026-08-20; +- tag wydania wskazuje commit `22a2168842a9ed4fdd15dd6761cd109c56bcd3b5`. + +Analiza i wszystkie odsyłacze do kodu CodexBar są przypięte do powyższego commitu `main`, aby późniejsza zmiana repozytorium nie zmieniła znaczenia raportu. + +## Jak działa obecnie Codex Limits + +Obecna integracja jest wąska, ale ma dobrą granicę zaufania: + +- [`CodexClient`](../../Sources/CodexLimits/CodexClient.swift) wyszukuje wyłącznie lokalny program Codex w znanych lokalizacjach, uruchamia `codex app-server --stdio` i komunikuje się z nim przez standardowe wejście/wyjście; +- jedna paczka RPC odczytuje limity, użycie i konto; klient ponawia odczyt, jeśli podczas pobierania przychodzą aktualizacje, i nie publikuje niespójnej migawki; +- równoległe żądania współdzielą jedno trwające pobranie, a zerwane połączenie jest jednokrotnie odbudowywane; +- [`UsageMonitor`](../../Sources/CodexLimits/UsageMonitor.swift) utrzymuje jedną migawkę konta, jeden stan źródła i jedną partycję historii; nie uruchamia dwóch odświeżeń naraz i po błędzie zachowuje poprzednie dane ze statusem awarii; +- [`UsageModels`](../../Sources/CodexLimits/UsageModels.swift) i [`UsageHistory`](../../Sources/CodexLimits/UsageHistory.swift) są dziś semantycznie związane z Codexem: główne okno to tygodniowe `10080` minut, a klucz partycji historii identyfikuje konto, lecz nie dostawcę; +- [`LocalActivityCollector`](../../Sources/CodexLimits/LocalActivityCollector.swift) czyta lokalną aktywność wyłącznie z `~/.codex/sessions`; +- ADR [`0001`](../adr/0001-local-codex-app-server-as-usage-source.md) świadomie odrzuca kopiowanie poświadczeń i scraping GUI, a [`MEASUREMENT-CONTRACT`](../MEASUREMENT-CONTRACT.md) rozróżnia fakty konta, fakty lokalne i wartości pochodne oraz preferuje brak wyniku nad słabym szacunkiem. + +To oznacza, że rozszerzenie nie powinno polegać na dopisaniu kolejnych warunków do `CodexClient`. Najpierw trzeba wynieść **tożsamość integracji i stan per integracja** o jeden poziom wyżej, pozostawiając sam klient Codex jako istniejącą, sprawdzoną implementację. + +## Metoda CodexBar + +### 1. Dostawca jest opisem zachowania, nie tylko nazwą + +CodexBar ma zamkniętą, generowaną listę natywnych dostawców w [`ProviderManifest`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderManifest.swift#L1-L77). Każdy wpis jest rozwijany do obszernego [`ProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderDescriptor.swift#L298-L421), który łączy między innymi metadane, prezentację, ustawienia, poświadczenia, plan pobierania i konfigurację CLI. Rejestr przechowuje deskryptory w kolejności i umożliwia lookup po identyfikatorze. + +W aplikacji istnieje druga, celowo cieńsza warstwa [`ProviderImplementation`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift#L4-L98) oraz jej [`ProviderImplementationRegistry`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift#L4-L47). Oddziela to rdzeń pobierania od dostępności runtime, ustawień i akcji UI. + +Jest to skuteczne dla aplikacji obsługującej kilkadziesiąt dostawców, ale za duże dla czterech integracji w Codex Limits. W naszym przypadku statyczna tablica małych definicji będzie czytelniejsza; dynamiczna rejestracja nie daje jeszcze realnej korzyści. + +### 2. Dostawca ma uporządkowany plan prób + +Najbardziej wartościowym wzorcem jest [`ProviderFetchPlan`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift#L114-L197) i wykonujący go [`ProviderFetchPipeline`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift#L245-L393): + +- plan rozwiązuje strategie w określonej kolejności; +- każda strategia potrafi określić dostępność, wykonać pobranie i zdecydować, czy dany błąd dopuszcza fallback; +- pierwsza prawidłowa odpowiedź kończy łańcuch; +- wynik zawiera nie tylko migawkę, lecz również identyfikator strategii, rodzaj źródła i diagnostykę prób; +- błędy są klasyfikowane, anulowanie nie jest traktowane jak zwykła awaria, a ograniczone ponowienie uwzględnia sugerowane opóźnienie. + +To jest istota „metody CodexBar”, którą warto przenieść jako **własny kontrakt zachowania**, bez przenoszenia nazw, typów ani implementacji. + +### 3. Wszystkie źródła normalizują się do wspólnej migawki + +Wspólny model [`UsageSnapshot` i `RateWindow`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/UsageFetcher.swift#L3-L168) pozwala interfejsowi wyświetlać wyniki niezależnie od transportu. Model niesie okna limitów, czasy resetu, koszty, tożsamość i poziom pewności. + +Jednocześnie w dojrzałym CodexBar wspólna migawka zgromadziła pola właściwe konkretnym dostawcom. To ostrzeżenie przed nadmierną unifikacją. Codex Limits powinien normalizować tylko część rzeczywiście wspólną: identyfikator integracji i konta, okna limitów, czas pobrania, pochodzenie, pewność i ewentualny koszt. Lokalna aktywność i dane specyficzne dla dostawcy powinny pozostać osobnymi składnikami. + +### 4. Stan i odświeżanie są izolowane per dostawca + +CodexBar przechowuje migawki, błędy, etykiety źródła i diagnostykę prób osobno dla każdego dostawcy. [`ProviderRefreshCoordinator`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/ProviderRefreshCoordinator.swift#L1-L127) nadaje żądaniom generacje, anuluje zastępowane prace i zapobiega opublikowaniu starszego wyniku po nowszym. Przepływ publikacji sukcesu i błędu jest widoczny w [`UsageStore+Refresh`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/UsageStore%2BRefresh.swift#L335-L477) oraz [obsłudze wyników](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/UsageStore%2BRefresh.swift#L640-L859). Przy istniejących danych pierwsza przejściowa awaria może zostać wygaszona przez [`ConsecutiveFailureGate`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/UsageStoreSupport.swift#L90-L109). + +Codex Limits ma już prostszy odpowiednik dla jednego źródła. Po dodaniu dostawców potrzebuje kluczowania stanu `(integracja, konto)` i ochrony przed spóźnioną publikacją, ale nie musi kopiować rozbudowanego store'u CodexBar. + +### 5. Kosztowne źródła mają osobny cache + +CodexBar rozróżnia ręczne i automatyczne odświeżenia. Na przykład wynik komendy Claude może być używany w tle przez 15 minut, podczas gdy ręczne odświeżenie wymusza nowy odczyt; przekroczenie czasu resetu unieważnia cache. Pokazuje to [`ClaudeCLIUsageSpawnThrottle`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBar/ClaudeCLIUsageSpawnThrottle.swift#L3-L112). + +W Codex Limits warto zachować jedno globalne domyślne tempo tylko dla lekkich źródeł. Każda integracja powinna móc zadeklarować minimalny odstęp pobierania i warunek unieważnienia przy resecie. Ręczne odświeżenie nie powinno omijać ograniczenia dostawcy, jeśli grozi to rate limitami lub otwieraniem promptów systemowych. + +## Porównanie kontraktów + +| Obszar | Codex Limits obecnie | CodexBar | Zalecenie dla Codex Limits | +|---|---|---|---| +| Lista dostawców | Jeden zaszyty Codex | Manifest + rozbudowany rejestr deskryptorów | Statyczna lista 4 integracji; bez runtime pluginów | +| Transport | Jedno trwałe RPC do `codex app-server` | HTTP, CLI, PTY, OAuth, cookies, lokalne pliki/bazy | Transport zamknięty wewnątrz adaptera; domyślnie provider-owned CLI lub publiczne API | +| Kolejność źródeł | Jedno źródło | Uporządkowany plan strategii z warunkowym fallbackiem | Mała lista prób tylko tam, gdzie istnieją co najmniej dwa uzasadnione źródła | +| Wynik | Codexowa migawka, główne okno tygodniowe | Wspólna migawka + źródło, strategia, diagnostyka i pewność | Minimalna wspólna migawka; bez pól vendor-specific w rdzeniu | +| Tożsamość | Partycja po koncie | Stan i konta per dostawca | Klucz `(integrationID, stableAccountID)`; brak łączenia historii przy niepewnej tożsamości | +| Odświeżanie | Jedno trwające pobranie, zachowanie starej migawki po błędzie | Koordynator per klucz, generacje, anulowanie, cache per źródło | Rozszerzyć obecny mechanizm o klucz i generację; nie przepisywać całego store'u | +| Diagnostyka | Jeden stan źródła | Lista prób i końcowe źródło | Zapisywać krótkie, zredagowane przyczyny prób; UI pokazuje źródło i świeżość | +| Poświadczenia | Należą do procesu Codex; aplikacja ich nie kopiuje | Własny Keychain, obce tokeny, cookies i loginy CLI zależnie od źródła | Zachować obecny trust boundary; własny Keychain tylko dla klucza wpisanego przez użytkownika | +| Lokalne dane | Sesje Codex i pomiary pochodne | Również logi i lokalne bazy innych klientów | Trzymać lokalną aktywność oddzielnie od autorytatywnych limitów | + +## Konkretne integracje + +### Codex — łatwe + +CodexBar w trybie automatycznym potrafi próbować PAT, OAuth i CLI, a web pozostawia jako źródło jawnie wybrane. Kolejność jest opisana w [`CodexProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift#L28-L155). Bieżąca domyślna ścieżka CLI nie parsuje PTY: uruchamia jednorazowo `codex app-server`, odczytuje `account/rateLimits/read` i `account/read`, po czym zamyka proces ([`UsageFetcher`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/UsageFetcher.swift#L1124-L1170)). Osobny [`CodexStatusProbe`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift#L60-L146) istnieje jako inna powierzchnia diagnostyczna, nie jako opis domyślnego odczytu usage. + +Codex Limits ma już głębszą dla swojego celu ścieżkę: utrzymuje połączenie z `app-server` i scala `account/rateLimits/read`, `account/usage/read` oraz `account/read` z aktualizacjami przychodzącymi w trakcie pobierania. Podstawowa ścieżka CLI CodexBar jest odczytem one-shot i nie ma odpowiednika pełnego `account/usage/read`. Oprócz bogatszych danych Codex Limits unika przejmowania OAuth/PAT i parsowania terminala. Należy tę ścieżkę jedynie opakować jako integrację `codex`. Trudność jest niska, a ryzyko regresji niewielkie, jeśli sam `CodexClient` pozostanie niezmieniony. + +### Claude Code — średnio łatwe jako opt-in, trudne jako bezobsługowe źródło + +CodexBar rozróżnia aplikację i runtime CLI. Dla aplikacji tryb automatyczny próbuje OAuth, CLI, a następnie web; dla runtime CLI preferuje web przed CLI. Wybrane konto jest granicą autorytetu: uszkodzone poświadczenia wybranego konta nie powinny po cichu przejść na konto ambient. Reguły są w [`ClaudeSourcePlanner`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift#L169-L234) i [`ClaudeProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift#L231-L288). + +W praktyce źródła obejmują bezpośredni odczyt OAuth, prywatne endpointy webowe z cookies oraz fallback przez PTY i parsowanie `/usage` lub `/status`; opisuje je dokumentacja [`docs/claude.md`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/claude.md#L20-L134) i [sekcja CLI/logów](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/claude.md#L201-L248). + +Dla Codex Limits v1 rekomenduję **wyłącznie jawnie włączaną integrację z oficjalnym `statusLine`**. Obecna dokumentacja Claude Code definiuje dokładnie `rate_limits.five_hour` i `rate_limits.seven_day`, pola `used_percentage` i `resets_at`; wywołania są event-driven, a opcjonalny `refreshInterval` dodaje odświeżanie czasowe ([oficjalny kontrakt `statusLine`](https://code.claude.com/docs/en/statusline)). Ograniczenie jest uczciwe i mierzalne: dane pojawiają się dla subskrybentów Claude.ai po pierwszej odpowiedzi API w aktywnej sesji, więc nie jest to niezależny od sesji polling konta. + +PTY można rozważyć dopiero jako osobny przyszły eksperyment kompatybilności, nie jako równorzędny fallback v1. Nie rekomenduję na start czytania tokenów OAuth Claude, cookies przeglądarki, prywatnych endpointów ani parsowania `/usage`. Wtedy „podobna metoda” oznacza pochodzenie, normalizację i izolację stanu, a nie ten sam dostęp do poświadczeń. + +### xAI / Grok — dobry kandydat do średniego spike'u ACP + +CodexBar planuje kolejno CLI ACP, proxy OAuth, web cookies i gRPC OAuth; plan widać w [`GrokProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift#L111-L201). Pierwsza strategia rzeczywiście uruchamia `grok agent stdio` i wywołuje `x.ai/billing`. Dokumentacja odnotowuje wersyjną usterkę `Method not found` w starym Grok `0.1.210`, po której CodexBar przechodzi do tokenu z `~/.grok/auth.json`, proxy CLI, cookies lub prywatnego gRPC. Szczegóły są w [`docs/grok.md`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/grok.md#L11-L103) oraz [opisie poświadczeń i lokalnych sygnałów](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/grok.md#L104-L206). + +Aktualny `main` oficjalnego Grok Build, commit [`19d42e35`](https://github.com/xai-org/grok-build/commit/19d42e35c07a9c9244f03f6df0c4c353f970d4f9), nadal zawiera handler i schemat rozszerzenia [`x.ai/billing`](https://github.com/xai-org/grok-build/blob/19d42e35c07a9c9244f03f6df0c4c353f970d4f9/crates/codegen/xai-grok-shell/src/extensions/billing.rs#L1-L120). Oznacza to, że **czysty probe ACP jest dziś najbliższym kandydatem do metody Codex Limits**, choć dostępność w zainstalowanych wydaniach CLI nadal trzeba wykrywać w runtime. + +Rekomendacja: niezależny spike `grok agent stdio` → initialize → `x.ai/billing`, z capability/version probe, tolerancyjnym dekoderem pól opcjonalnych, timeoutem, zabiciem procesu i wynikiem „source unavailable” przy braku metody. Nie dodawać fallbacku do tokenu z `auth.json`, cookies ani prywatnego gRPC. Jeśli oficjalny ACP nie jest dostępny w wersji użytkownika, pokazujemy lokalną aktywność albo brak limitu — nie obchodzimy kontraktu dostawcy. + +### OpenCode — łatwe dla lokalnej aktywności, prywatne limity są trudne + +Ważne jest rozróżnienie dwóch funkcji CodexBar: + +- dostawca **OpenCode** odczytuje limity workspace z serwisu `opencode.ai` przez cookies przeglądarki i prywatne wywołania serwerowe; implementację wyboru źródła pokazuje [`OpenCodeProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift#L23-L143); +- **OpenCode Go** próbuje lokalnej bazy, API i web. Lokalny wynik może zostać wzbogacony bardziej autorytatywnym API/web i jest oznaczany jako szacowany, gdy zostaje sam. Kolejność i merge opisuje [`OpenCodeGoProviderDescriptor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift#L121-L305). + +CodexBar czyta lokalną SQLite OpenCode Go w trybie read-only i rekonstruuje koszty z tabel wiadomości/części; szczegóły schematu i zapytań są w [`OpenCodeGoLocalUsageReader`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift#L49-L214). Dokumentacja [`docs/opencode.md`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/opencode.md#L10-L50) opisuje różnicę między prywatną ścieżką web a API/local dla Go. + +Dla Codex Limits najłatwiejszy sensowny zakres to lokalna aktywność OpenCode przez oficjalny `opencode serve`, nie przez bazę CodexBar. W aktualnym wydaniu [`v1.18.20`](https://github.com/anomalyco/opencode/releases/tag/v1.18.20) serwer publikuje OpenAPI, health, projekty, sesje, wiadomości/providerów i SSE ([oficjalna dokumentacja serwera](https://opencode.ai/docs/server/), [endpointy sesji przypięte do wydania](https://github.com/anomalyco/opencode/blob/7248bc1964b13fa67e601733f89ee9dc6dfa0563/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts#L78-L188)). `SessionInfo` zawiera koszt, tokeny, model/provider oraz relację rodzic–dziecko ([oficjalny schemat](https://github.com/anomalyco/opencode/blob/7248bc1964b13fa67e601733f89ee9dc6dfa0563/packages/schema/src/v1/session.ts#L537-L568)). To jest bardzo bliski odpowiednik zasady użytej dziś z `codex app-server`, chociaż dostarcza lokalnej aktywności, a nie gwarantowanego limitu konta. + +Bezpośrednie związanie się z wewnętrznym schematem SQLite jest umiarkowanie kruche i niepotrzebne, gdy istnieje lokalny serwer/OpenAPI; eksport i `opencode stats` mogą być fallbackiem dla starszych wersji ([oficjalne CLI](https://opencode.ai/docs/cli/)). Autorytatywne limity zwykłego OpenCode przez prywatne funkcje webowe nie pasują do v1. Jeśli celem jest OpenCode Go i istnieje publiczny endpoint z kluczem, można go dodać później, zapisując **klucz podany przez użytkownika** w naszym Keychain; lokalne koszty nadal muszą mieć etykietę „szacunek”, nie „pozostały limit”. + +## Poświadczenia, prywatność i transport + +CodexBar rozwiązuje wiele realnych problemów Keychain i cookies: własny cache jest izolowany zakresami, zapis ma ochronę przed spóźnionym wynikiem, a odczyty w tle unikają interaktywnych promptów. Dokument [`keychain-prompts.md`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/docs/keychain-prompts.md#L11-L70) pokazuje jednak koszt takiej funkcji: dostęp do Safe Storage przeglądarki i obcych wpisów Keychain może wywoływać systemowe monity i wymaga osobnych reguł dla pracy w tle. + +CodexBar dodatkowo centralizuje projekcję ustawień na poświadczenia i środowisko procesu. Wybrane konto najpierw usuwa ambient credentials, a dopiero potem wstrzykuje własne, co ogranicza cichy cross-account fallback: [`ProviderCredentialAdapter`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderCredentialAdapter.swift#L71-L218) i [`ProviderEnvironmentResolver`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Providers/ProviderEnvironmentResolver.swift#L3-L29). Sam wzorzec izolacji konta jest cenny; framework przejmowania wielu rodzajów credentiali nie jest nam potrzebny. + +Warstwa HTTP CodexBar ogranicza ponowienia do bezpiecznych przypadków i pilnuje przekierowań do tego samego hosta po HTTPS w [`ProviderHTTPClient`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/ProviderHTTPClient.swift#L38-L252). Logi redagują e-maile, nagłówki cookies i tokeny Bearer w [`LogRedactor`](https://github.com/steipete/CodexBar/blob/f74117aeb7a9ee02a78c0f08ca354ff26b2292e0/Sources/CodexBarCore/Logging/LogRedactor.swift#L12-L50). Te reguły bezpieczeństwa są warte odtworzenia niezależnie, jeśli Codex Limits doda własny HTTP. + +Proponowana polityka Codex Limits: + +1. automatyczne źródło nie może pokazać promptu logowania ani Keychain; +2. nie czytamy domyślnie cookies przeglądarki, cudzych wpisów Keychain ani plików tokenów; +3. jeśli publiczne API wymaga klucza, użytkownik wkleja go świadomie, a Codex Limits zapisuje go we własnym wpisie Keychain; +4. token, cookie i surowa odpowiedź nie trafiają do logów, historii ani crash reportów; +5. przekierowanie HTTP nie może przenieść nagłówka autoryzacji na inny origin; +6. błędna tożsamość konta zatrzymuje łańcuch zamiast uruchamiać fallback do „jakiegoś” konta ambient; +7. każda migawka przechowuje `source`, `fetchedAt`, `confidence` i stabilny klucz konta albo jest oznaczona jako niepartycjonowalna. + +## Minimalna architektura docelowa + +Nie potrzeba kopii `ProviderDescriptor`. Wystarczą trzy własne pojęcia: + +- **Integration** — stały identyfikator, nazwa i funkcja pobrania; +- **Probe** — opcjonalne pojedyncze źródło wewnątrz integracji, z kontrolą dostępności, timeoutem i klasyfikacją błędu; +- **Allowance snapshot** — wspólne okna limitów oraz metadane źródła, pewności, czasu i konta. + +Przepływ: + +```text +odśwież(integracja, konto) + → wybierz statyczną definicję integracji + → uruchamiaj jej źródła w ustalonej kolejności + → po prawidłowym wyniku normalizuj i opublikuj tylko dla tego klucza + → po błędzie „źródło niedostępne” przejdź do kolejnego źródła + → po błędzie auth / account mismatch / parse ambiguity zatrzymaj łańcuch + → zachowaj ostatnią poprawną migawkę i pokaż jej świeżość oraz błąd +``` + +Stan monitora należy przechowywać jako kolekcję kluczowaną przez integrację i konto, zamiast rozbudowywać pojedyncze `accountSnapshot`. Historia musi dostać identyfikator integracji; w przeciwnym razie te same skróty kont lub partycja `unknown` mogłyby mieszać dane vendorów. Dotychczasowa tygodniowa semantyka Codex nie może zostać globalnym założeniem — okna muszą pochodzić z integracji. + +Nie trzeba jednak wykonywać tej migracji przed pierwszym wydaniem. Najmniejszy bezpieczny wariant v1 pozostawia obecny `UsageMonitor`, historię i prognozy jako ścieżkę **Codex-only**, a nowe źródła publikuje do małego stanu kart przeglądowych kluczowanego przez integrację. Karta używa istniejącej semantyki okna (`remainingPercent`, reset, czas trwania) i dodaje tylko provenance, świeżość oraz pewność. Claude i Grok dostają historię lub forecast dopiero po potwierdzeniu stabilnej tożsamości konta i jakości wielu kolejnych próbek. Dzięki temu nie uniwersalizujemy z góry `UsageSnapshot` ani silnika analitycznego. + +Lokalną aktywność należy modelować równolegle: + +```text +account allowance ← źródło autorytatywne dostawcy +local activity ← pliki/sesje/CLI na tym Macu +derived analytics ← wyłącznie jawne połączenie powyższych, z coverage +``` + +To zachowuje obecny kontrakt pomiarowy i zapobiega prezentowaniu kosztu wyliczonego z lokalnej bazy jako pozostałego limitu konta. + +## Kolejność wdrożenia i trudność + +| Etap | Zakres | Trudność | Ryzyko | +|---|---|---:|---:| +| 1 | `IntegrationID` i mały stan kart: okna, źródło, świeżość, pewność; obecny Codex bez zmian | Niska | Niskie | +| 2 | Odświeżanie per integracja z generation guard; bez historii i forecastu nowych źródeł | Niska–średnia | Niskie | +| 3 | Grok: techniczny spike oficjalnego ACP `x.ai/billing`, z capability probe | Średnia | Średnie | +| 4 | Claude: wyłącznie oficjalny opt-in `statusLine` w v1 | Niska–średnia | Niskie–średnie | +| 5 | OpenCode: lokalna aktywność przez oficjalny `opencode serve`/OpenAPI | Niska–średnia | Niskie | +| 6 | Historia/forecast per `(integracja, konto)` dopiero po potwierdzeniu jakości danych | Średnia–wysoka | Średnie | +| Później | OpenCode Go: publiczne API z własnym Keychain, jeśli potwierdzony popyt | Średnia | Średnie | +| Odrzucone w v1 | Cookies przeglądarki, prywatne endpointy, obce tokeny, Grok gRPC, prywatne funkcje OpenCode | Wysoka | Wysokie | + +Praktyczny pierwszy milestone powinien kończyć się na etapach 1–2. Pierwszym **spike'em protokołu** powinien być Grok, bo najbardziej przypomina obecną metodę Codex; pierwszym **niskoryzykownym źródłem produktowym** może być Claude `statusLine`. OpenCode trafia do osobnej części Local Activity, a nie do kart limitu konta. + +## Zasada „bez kopiowania kodu” + +Implementacja powinna powstać jako niezależny projekt kontraktów na podstawie obserwowalnego zachowania i oficjalnej dokumentacji dostawców: + +- używamy własnych nazw typów, własnego modelu błędów i własnych testów; +- CodexBar służy wyłącznie do identyfikacji wzorców, kolejności źródeł, znanych awarii i ryzyk; +- fixture'y parserów pozyskujemy z uruchomień własnych CLI lub oficjalnych przykładów, nie z testów ani kodu CodexBar; +- nie przenosimy parserów, endpointowych wrapperów, zapytań SQL, struktur deskryptorów ani mechanizmów Keychain; +- każdą prywatną powierzchnię najpierw zastępujemy publicznym API/CLI, a jeśli nie istnieje — ograniczamy zakres funkcji zamiast kopiować workaround. + +## Konkluzja + +CodexBar potwierdza, że multi-provider jest wykonalny i że dobrym rdzeniem jest **uporządkowany łańcuch źródeł normalizujących się do jednej migawki**. Nie potwierdza natomiast, że wszystkie jego źródła są odpowiednie dla Codex Limits. Obecna metoda Codex Limits jest bardziej konserwatywna, łatwiejsza do obrony prywatnościowo i powinna stać się wzorcem zaufania dla kolejnych integracji. + +Najłatwiejsze: opakowanie Codex, wspólny stan per integracja, Claude przez jawny `statusLine` oraz lokalna aktywność OpenCode przez `opencode serve`.\ +Umiarkowane: Grok jako beta przez oficjalne ACP z wykrywaniem capability.\ +Najtrudniejsze i niewarte v1: prywatne limity OpenCode oraz wszelkie fallbacki wymagające przejmowania tokenów/cookies. diff --git a/docs/research/enterprise-analytics-feasibility-2026-08-06.md b/docs/research/enterprise-analytics-feasibility-2026-08-06.md new file mode 100644 index 0000000..2c99762 --- /dev/null +++ b/docs/research/enterprise-analytics-feasibility-2026-08-06.md @@ -0,0 +1,202 @@ +# Enterprise analytics for Codex Limits: feasibility and counterevidence + +Date: 2026-08-06\ +Product: `codex-limits`\ +Scope: OpenAI/Codex organization analytics, local data collection, competition, privacy, deployment, and pricing + +## Executive verdict + +The proposed **generic enterprise analytics product for companies using Codex is not a strong direction in its current form**. + +The main reason is not implementation difficulty. It is that OpenAI now provides most of the proposed value natively: Codex adoption and activity analytics, active users, credits, tokens, model and metered-item breakdowns, users/groups/agents leaderboards, lines of code, plugin and skill usage, CSV exports, cost estimates, per-workspace/group/user limits, alerts, and administration APIs. OpenAI expanded this surface materially between May and August 2026. Sources: [Global Admin Console](https://help.openai.com/en/articles/12289294-global-admin-console), [Enterprise/Edu release notes](https://help.openai.com/en/articles/10128477-chatgpt-enterprise-edu-release-notes), [usage limits and Spend Controls API](https://help.openai.com/en/articles/20001001-setting-usage-limits-for-chatgpt-enterprise-and-edu), [Admin keys](https://help.openai.com/en/articles/20001407). + +The remaining credible gap is narrower: + +1. near-real-time, **local task diagnostics** such as context growth, cache behavior, compaction, tool waits, concurrency, agent trees, and the evidence behind one expensive or failed session; +2. privacy-preserving developer coaching on the employee's device; +3. potentially, cross-vendor normalization across Codex, Claude Code, Cursor, and GitHub Copilot. + +Only the first gap is already close to the current codebase. It supports a standalone OSS product for individual developers, but it does **not yet support an enterprise control plane or per-seat business**. The enterprise work should be deferred until design partners identify a concrete decision that the official OpenAI console and APIs cannot support and agree to pay for it. + +## Method and source rules + +Facts below come from current first-party documentation from OpenAI, the official `openai/codex` repository, GitHub, Cursor, and public-sector data-protection guidance. Product and commercial conclusions are labeled as inferences. No claim is made that public pricing proves willingness to pay for Codex Limits. + +## 1. What OpenAI already gives organizations + +### Facts + +OpenAI's Global Admin Console already offers a Codex-specific analytics view. Depending on workspace eligibility and data availability, it includes: + +- active users, credits, tokens, and messages; +- user, group, and agent leaderboards; +- breakdowns by product, metered item, and model; +- Codex message runs, lines of code, plugin calls, skills, and code-review activity; +- date filtering and CSV export. + +Codex and credit analytics are typically refreshed within 1–6 hours and currently retain up to 120 days in the console; longer credit history can be obtained from billing reports. [Global Admin Console](https://help.openai.com/en/articles/12289294-global-admin-console) + +The rollout is recent and active: + +- on May 21, 2026, OpenAI announced Codex analytics with active users, credits/tokens, threads/turns, user leaderboards, plugin usage, accepted lines of code, and model usage; +- on July 16, 2026, it added workspace-scoped Admin keys for Codex analytics, cost reporting, group management, and Spend Controls, plus up to 120 days of Codex analytics; +- by August 4, 2026, OpenAI documented group analytics, estimated dollar values in the Cost API and Codex analytics API, and group/user/workspace usage limits in the Global Admin Console. + +Source: [ChatGPT Enterprise & Edu release notes](https://help.openai.com/en/articles/10128477-chatgpt-enterprise-edu-release-notes). + +Eligible Enterprise and Edu admins can automate the same limit management through the Spend Controls API. It reads and updates monthly limits at workspace, group, and user levels. The console also supports increase requests, usage alerts, a workspace overage cap, and analytics/invoice reconciliation. [Manage usage limits and overages](https://help.openai.com/en/articles/20001001-setting-usage-limits-for-chatgpt-enterprise-and-edu) + +Workspace-scoped Admin keys can be restricted by endpoint category. Depending on permissions, they can read workspace analytics and costs, read Codex analytics, manage service accounts and groups, access compliance logs, and automate usage limits. [Managing Admin keys](https://help.openai.com/en/articles/20001407) + +OpenAI's broader Workspace Analytics product also includes per-user metrics, SCIM-group segmentation, license/adoption tracking, benchmarks, task-category insights, impact surveys, and CSV reports. It deliberately does not expose message text, file contents, or item-level compliance records. Its refresh is not real-time: typically 6–12 hours, with a target of up to 48 hours. [Workspace analytics](https://help.openai.com/en/articles/10875114-user-analytics-for-chatgpt-enterprise-and-edu-public-beta) + +The Compliance Platform gives Enterprise and Edu customers immutable append-only compliance events plus state-oriented APIs for SIEM, DLP, eDiscovery, and audit workflows. The logs platform retains events for 30 days, so customers needing longer retention must continuously download and retain them. [OpenAI Compliance Platform](https://help.openai.com/en/articles/9261474-openai-compliance-platform-for-enterprise-customers) + +OpenAI also supplies the surrounding enterprise control layer: SSO, SCIM, provisioning/deprovisioning, RBAC, retention controls, data residency for eligible customers, and a DPA. It states that business inputs and outputs are not used for model training by default. [Identity and provisioning](https://help.openai.com/en/articles/9672121), [RBAC](https://help.openai.com/en/articles/11750701-rbac), [business data privacy](https://openai.com/business-data/). + +### Inference + +A Codex Limits enterprise dashboard centered on **who used Codex, how many tokens or credits they consumed, which models they used, and who is nearing a limit** would be a direct duplicate of a first-party product. + +This is worse than an ordinary competitive overlap: + +- OpenAI owns the authoritative billing and identity data; +- it does not need an endpoint agent installed on employee machines; +- it already has the admin relationship, security review, tenant model, invoices, and control plane; +- it is expanding the surface quickly enough that small remaining gaps can close before a third-party product reaches enterprise readiness. + +The July/August availability of a Codex analytics API also removes “better exports and custom dashboards” as a durable moat. A customer can connect the official data to its existing warehouse or BI stack without buying another endpoint application. + +## 2. What local Codex collection can and cannot know + +### Facts: supported local signals + +The supported Codex app-server exposes the current signed-in account's rate limits, effective monthly credit limit when available, spend-control state, reset credits, and daily account token activity. It also exposes locally stored threads, turns, items, parent/descendant relationships, and token-usage events. [Codex app-server README](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) + +The local thread protocol can reveal much richer diagnostic events than the admin console advertises, including command duration and output, file changes, MCP and collaboration calls, sleep/wait events, context compaction, thread hierarchy, and exact upstream usage for live raw responses when the experimental event is enabled. [Turn and item events](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#turn-events) + +The same surface can also expose sensitive content: user messages, agent replies, reasoning summaries, commands, working directories, command output, file paths and diffs, and MCP arguments/results. [Thread item schema](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#items) + +`thread/list` is a view of locally stored threads. It can filter by local working directory and source kind, and the default behavior may scan local rollout JSONL files to repair metadata unless `useStateDbOnly` is requested. Ephemeral threads are not durable history. [Thread listing and persistence](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#example-list-threads-with-pagination--filters) + +OpenAI explicitly says that Codex use on web or delegated to the cloud is available in the Compliance API, while usage in local environments is not. [Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan) + +### Inference: coverage ceiling + +A local collector can be excellent at explaining the activity stored on **that machine**, but it is not an organization source of truth: + +- it cannot guarantee coverage of Codex web/cloud work, other devices, deleted or ephemeral history, machines on which the agent is absent, or periods in which collection failed; +- local token activity is not automatically a one-to-one explanation of the authoritative subscription or workspace credit ledger; +- an enterprise aggregate built from laptops needs explicit coverage and freshness metrics, deduplication, identity mapping, version compatibility, and a visible “unattributed” remainder; +- parsing rollout files couples the product to implementation details and sensitive records. The supported app-server should be preferred wherever it exposes the required facts. + +The local gap is therefore a **diagnostic gap**, not a better billing dataset. The useful question is “what happened inside this local task?” rather than “what did the organization spend?”. + +## 3. Is the remaining gap commercially strong enough? + +### Candidate value that is not yet clearly duplicated + +| Candidate | Evidence that the gap exists | Durability | Enterprise value assessment | +|---|---|---:|---| +| Near-real-time limit and task alerts | Official Codex analytics refreshes in 1–6 hours; Workspace Analytics is slower. [Global Admin Console](https://help.openai.com/en/articles/12289294-global-admin-console) | Low | Freshness alone is unlikely to justify a new vendor. Native notifications can close the gap. | +| Per-task local diagnostics | App-server exposes compaction, waits, commands, tools, thread trees and detailed usage events. [App-server events](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#turn-events) | Medium | Useful to developers and platform teams if it produces a concrete recommendation, not just charts. | +| Long-term history | Native Codex console history is currently up to 120 days. [Global Admin Console](https://help.openai.com/en/articles/12289294-global-admin-console) | Low | Official API/export already lets customers retain history externally. | +| Private on-device coaching | Rich analysis can stay local and avoid transmitting task content | Medium | Strong OSS/user value; weak reason for an admin to pay per employee unless a measurable outcome is proved. | +| Cross-vendor coding-agent analytics | GitHub, Cursor, and OpenAI each expose separate data models | Medium-high | Potentially durable, but it is a different and much larger product than Codex Limits. | +| Developer-effectiveness/ROI analysis | OpenAI already offers adoption, LoC and self-reported impact; GitHub relates Copilot adoption to PR lifecycle signals. [Workspace Analytics](https://help.openai.com/en/articles/10875114-user-analytics-for-chatgpt-enterprise-and-edu-public-beta), [GitHub Copilot metrics](https://docs.github.com/en/copilot/concepts/copilot-usage-metrics/copilot-metrics) | Low-medium | Tempting but methodologically dangerous; correlation is not causal productivity. | + +### Competitive evidence + +GitHub Copilot already provides dashboard, API and NDJSON usage metrics at enterprise, organization, repository, and user level, including adoption, engagement, code generation, acceptance, agent usage, and pull-request lifecycle. It also documents telemetry coverage and version limitations. [GitHub Copilot usage metrics](https://docs.github.com/en/copilot/concepts/copilot-usage-metrics/copilot-metrics), [LoC metric limitations](https://docs.github.com/en/enterprise-cloud@latest/copilot/reference/copilot-usage-metrics/lines-of-code-metrics) + +Cursor Teams includes real-time usage visibility and spend alerts, and its enterprise offering advertises advanced analytics/reporting, audit logs, SCIM, access controls, and an AI code tracking API. [Cursor Teams pricing update](https://cursor.com/blog/teams-pricing-june-2026), [Cursor pricing](https://cursor.com/pricing). + +### Inference + +Organization analytics and spend controls are becoming **table stakes of the coding-agent platform**, not an independent category with a strong moat. A Codex-only analytics vendor sits in the least defensible position: it lacks authoritative data while the platform owner can ship the same chart or endpoint directly. + +The strongest possible commercial wedge is not another dashboard but a workflow such as: + +> Diagnose why a particular task exhausted context or credits, show the evidence locally, and recommend one safe change that prevents recurrence. + +That wedge still needs validation. A graph of cache, tool calls, or compactions is not in itself an enterprise outcome. + +## 4. Privacy, compliance, and deployment burden + +### Facts + +The local Codex protocol contains employee-linked prompts, replies, commands, directories, outputs, paths, diffs, and tool activity. [Thread item schema](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md#items) + +GDPR principles require lawful, fair and transparent processing, a specified purpose, data minimization, accuracy, and limited retention. [European Commission: processing principles](https://commission.europa.eu/law/law-topic/data-protection/rules-business-and-organisations/principles-gdpr/overview-principles/what-data-can-we-process-and-under-which-conditions_en) + +Official worker-monitoring guidance warns that excessive monitoring can harm workers' privacy and trust, calls for necessity and proportionality, and says a DPIA is required before high-risk monitoring. It also recommends involving workers and informing them before monitoring begins. [ICO: data protection and monitoring workers](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/employment/monitoring-workers/data-protection-and-monitoring-workers/) + +OpenAI's own enterprise product advertises no training by default, AES-256 at rest, TLS 1.2+ in transit, retention controls, data residency, certifications, RBAC, and DPA support. [OpenAI business data privacy](https://openai.com/business-data/) + +Codex is available in the official desktop app on macOS and Windows, while the Codex CLI and IDE surfaces broaden the environments an organization can use. [Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan) + +### Inference + +An enterprise Codex Limits agent would create a second, unusually sensitive data processor on every developer machine. Even if only aggregates leave the device, the software reads records that may contain code, prompts, file paths, terminal output, secrets, customer names, and security findings. + +A credible hosted enterprise offering would therefore need at least: + +- an explicit collection schema and employee-visible preview of transmitted fields; +- strict content exclusion, tenant isolation, encryption, retention/deletion controls and auditability; +- DPA/subprocessor documentation and likely customer security reviews; +- signed/notarized distribution, managed updates and fleet health; +- macOS and Windows coverage, or an explicit and commercially limiting macOS-only scope; +- an answer for works councils, DPIAs, opt-out, and use of metrics in performance management. + +These are not optional polish. They are part of the product. They make a “small per-user fee” difficult unless the deployment produces material, provable savings. + +## 5. Pricing reality + +### Facts + +ChatGPT Business currently costs $20 per user/month annually or $25 monthly and already includes usage analytics, budgeting, spend controls, centralized administration, SSO, and Codex access. [OpenAI business pricing](https://openai.com/business/pricing/) + +Cursor's current Teams price is $32 per seat/month annually or $40 monthly and includes usage visibility and spend alerting alongside the coding product itself. [Cursor Teams pricing](https://cursor.com/blog/teams-pricing-june-2026) + +These prices are for the underlying AI product, not for a standalone analytics add-on. They do not prove the correct price for Codex Limits. + +### Inference + +At EUR 5 per user/month: + +- 100 seats produce only EUR 6,000 ARR; +- 500 seats produce EUR 30,000 ARR. + +For a product that requires enterprise sales, security review, endpoint rollout, cross-platform support, compatibility work, and incident support, the 100-seat economics are poor. At the same time, EUR 5 is already 20–25% of the list price of ChatGPT Business, whose native analytics are included. + +Per-seat pricing is therefore not inherently wrong, but it needs either: + +1. a meaningful annual minimum and larger customers; or +2. a direct, measurable financial outcome, such as preventing more credit waste than the product costs; or +3. a broader multi-vendor platform whose value grows with the number of managed users and tools. + +No current evidence proves any of these conditions. Pricing should not be designed before the paid decision is validated. + +## 6. Recommendation and falsifiable gates + +### Decision now + +1. **Proceed conditionally with standalone OSS.** Position it as local developer observability and diagnosis, not enterprise FinOps. Start with one valuable workflow: explain one session's consumption and recommend the next action. +2. **Do not build an enterprise control plane now.** It would duplicate a fast-moving first-party surface and impose the largest engineering and compliance burden before demand is proven. +3. **Do not build endpoint aggregation for generic organization analytics.** If enterprise discovery later succeeds, use OpenAI's Codex Analytics/Admin APIs for authoritative organization facts and add local collection only for the diagnostic fields that cannot be obtained otherwise. +4. **Treat cross-vendor analytics as a separate product thesis.** It may be more defensible, but it multiplies integrations, schemas, buyer questions and support obligations. It should not be smuggled into the current roadmap as an “enterprise edition.” + +### Evidence required before reversing the enterprise decision + +Build a paid pilot only after all of the following are true: + +- at least three target companies show the exact official OpenAI dashboard/API they use; +- they identify the same recurring decision they still cannot make; +- that decision requires local diagnostic data, not merely a preferred visualization; +- security/privacy stakeholders accept the proposed field-level collection schema; +- at least two design partners agree to a paid pilot or a credible annual commitment; +- the expected contract value covers endpoint deployment and support rather than relying on a nominal per-seat fee. + +Until then, the smallest defensible strategy is: + +> full-featured local OSS for developers; no enterprise backend; revisit commercialization only around a proven diagnostic workflow or a separately validated multi-vendor product. diff --git a/docs/research/grok-build-acp-runtime-contract-2026-08-22.md b/docs/research/grok-build-acp-runtime-contract-2026-08-22.md new file mode 100644 index 0000000..ec9e9d1 --- /dev/null +++ b/docs/research/grok-build-acp-runtime-contract-2026-08-22.md @@ -0,0 +1,78 @@ +# Grok Build ACP billing contract and runtime result + +> **Correction — 2026-09-10:** The source-exclusion conclusion below is superseded. The probe used bare `x.ai/billing`; ACP requires `_x.ai/billing` on the JSON-RPC wire. Official stable 1.0.25 accepts the corrected method and passed a real authenticated read. The historical 1.0.5 binary has not been retested with the prefix, so its billing capability remains unproven. Grok is restored as a development Beta; expanded release checks remain pending. See [current validation](grok-build-validation-2026-09-10.md). The original observations and interpretation are retained below as historical evidence. + +Date: 2026-08-22\ +Status: Stable 1.0.5 external collector rejected for v1 + +## Verdict + +The user's installed official Grok Build stable `1.0.5` binary executes normally, can start an external ACP agent, and completes `initialize`. It returns JSON-RPC `-32601 Method not found` only when Codex Limits asks that external ACP router for `x.ai/billing`; the result is identical with ordinary `agent stdio` and the source-recommended isolated `agent --no-leader stdio` mode. This does not diagnose a broken Grok installation or subscription. + +The xAI source tree contains and routes a billing handler in a source snapshot that declares version `1.0.5`. xAI does not publish a matching repository tag or GitHub release that proves this snapshot built the distributed binary, whose local version string reports build `5115b46bc909`. Source capability therefore does not override the measured public surface of the user-installed executable. + +Codex Limits defers Grok from shipped v1. It does not replace the missing method by reading credentials, reusing browser sessions, calling a private backend, or parsing the interactive TUI. + +## Primary-source contract + +xAI documents Grok Build agent mode as newline-delimited JSON-RPC 2.0 over the stdin and stdout of `grok agent stdio`. The source-recommended single-client form uses `--no-leader` so the caller owns the complete process rather than joining a shared leader. See [Agent mode](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-pager/docs/user-guide/15-agent-mode.md#L45-L70) and the [bounded NDJSON reader](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-acp-lib/src/line_reader.rs#L1-L40). + +The smallest billing flow is: + +1. start `grok agent --no-leader stdio` with a caller-owned process group; +2. send `initialize` with protocol version `1`, no filesystem or terminal capabilities, and non-interactive startup hints; +3. wait for its response; +4. send `x.ai/billing` with empty parameters and a literal `/`; +5. close stdin, then enforce the caller's graceful and forced termination bounds. + +`session/new` is unnecessary. It would create session, workspace, model, and persistence state without helping the process-global billing handler. The handler reads the process authentication manager directly. See the [ACP router](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs#L2180-L2465) and [billing auth gate](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-shell/src/extensions/billing.rs#L200-L205). + +An automatic collector must not invoke ACP `authenticate`; an unavailable cached login may otherwise enter an interactive flow. Authentication belongs only to an explicit user action in Settings. Billing requires first-party xAI OAuth/OIDC; a plain `XAI_API_KEY` does not satisfy the source's first-party auth predicate. See [authentication modes](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-shell/src/auth/model.rs#L136-L151). + +## Source-only response contract + +The source handler prefers: + +- `config.creditUsagePercent`; +- `config.currentPeriod.type`, `start`, and `end`; +- weekly and monthly period types kept distinct. + +It retains legacy `monthlyLimit.val`, `used.val`, `billingPeriodStart`, and `billingPeriodEnd`. Optional facts include on-demand cap and usage, prepaid balance, unified-billing state, subscription tier, and bounded billing history. `config` may be null. The top-level `on_demand_enabled` and `subscription_tier` fields use snake case while the configuration uses camel case. See [billing types and fixtures](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-shell/src/extensions/billing.rs#L13-L124). + +Backend `productUsage` is not represented by the exposed type and cannot support a trustworthy Build-only breakdown. A future reader must label `creditUsagePercent` as the shared Grok usage pool, not usage created only by Grok Build. + +The handler's internal HTTP request has a 15-second timeout and records a compact provider-owned unified-log entry after success. Codex Limits' stricter ten-second source deadline would take precedence, and successful future reads would remain demand-driven rather than frequent polling. See [billing request and logging](https://github.com/xai-org/grok-build/blob/9fabadea800fa6e2ed8ec91c4f45f02b7e2504f4/crates/codegen/xai-grok-shell/src/extensions/billing.rs#L200-L288). + +## Executed stable 1.0.5 spike + +The runtime test used `/Users/piotrciechowicz/.grok/bin/grok`, an installer-managed symlink to the stable `1.0.5` arm64 binary. Its 134,349,648 bytes and SHA-256 `3dfa7f04fbb5427a8fbead286591543aaecb478b3a0ab222c4329eca1a3b2f86` match independent downloads from both official xAI artifact origins. + +The harness used a fresh isolated home, removed `XAI_API_KEY`, advertised no filesystem or terminal capability, sent no prompt, created no ACP session, and made no billable model request. The final `--no-leader` run produced: + +| Observation | Result | +|---|---:| +| ACP `initialize` | Success | +| `x.ai/billing` | `-32601 Method not found` | +| Wall time including one-second shutdown boundary | 1.527 seconds | +| Child user + system CPU | 0.179 seconds | +| Maximum child RSS | 51,363,840 bytes | +| Captured stdout | 3,614 bytes | +| Captured stderr | 122 bytes | +| Isolated provider files | 34 files, 613,539 bytes | +| Graceful exit after stdin close | Did not finish within one second | +| Forced process-group cleanup | Complete; no surviving process | + +Authentication and subscription tier cannot change `Method not found`, because routing fails before the billing auth gate. The user's working Grok subscription is therefore unrelated to this v1 source rejection. + +## Re-entry gate + +Grok may return when an official distributed release exposes a supported external read-only billing method. That exact executable must then pass: + +- first-party personal and team authentication behavior without Codex Limits reading credentials; +- current and legacy schema fixtures, including zero-value ambiguity; +- the ten-second app deadline, cancellation, and one-second graceful-exit boundary; +- complete process-group cleanup and bounded output; +- transient RSS, CPU, and provider-owned write measurements; +- the shared lifecycle soak required of every shipped collector. + +The exact installed bytes match independent downloads from both official xAI artifact origins and execute normally. Apple's strict verifier still rejects this standalone CLI packaging, as it also does for the working official Claude Code CLI. That diagnostic is neither an auth/subscription result nor a reason for Grok's deferral. Executable hashing belongs only to explicit setup or detected replacement and never to ordinary refresh. diff --git a/docs/research/grok-build-validation-2026-09-10.md b/docs/research/grok-build-validation-2026-09-10.md new file mode 100644 index 0000000..7c490c8 --- /dev/null +++ b/docs/research/grok-build-validation-2026-09-10.md @@ -0,0 +1,76 @@ +# Grok Build billing validation + +Date: 2026-09-10\ +Status: Working development Beta; expanded release checks pending + +## Protocol correction + +The August 22 spike sent `x.ai/billing` directly on the JSON-RPC wire. ACP custom methods require an underscore, so the correct method is `_x.ai/billing`. The internal xAI handler name omits that transport prefix. The earlier `-32601` result did not prove that Grok lacked an external billing route. The historical 1.0.5 executable has not been retested with the corrected request. + +Official Grok Build stable 1.0.25 passed these sequential probes: + +| Probe | Result | +|---|---| +| Isolated provider home, `initialize` | Success | +| Isolated provider home, bare `x.ai/billing` | `-32601 Method not found` | +| Isolated provider home, `_x.ai/billing` | `-32000 Authentication required` | +| Normal CLI-managed login, `_x.ai/billing` | Valid weekly billing configuration and optional subscription tier | +| Compiled Swift collector using the same official CLI | Valid normalized snapshot, weekly period, CLI version 1.0.25, and a future reset | + +The authenticated probe made no model request, created no coding session, and started no login flow. Codex Limits did not read credentials or call a private backend. Account values, reset dates, identifiers, and raw provider output are intentionally absent from this report. + +## Measured process bounds + +The authenticated protocol probe measured: + +| Measure | Observation | +|---|---:| +| Wall time, including cleanup | 1.984 seconds | +| Maximum transient child RSS | 77,578,240 bytes | +| Child user + system CPU | 0.208 seconds | +| Captured stdout | 4,349 bytes | +| Captured stderr | 0 bytes | +| Process-group cleanup | Stopped with SIGTERM | + +These are one-operation measurements, not an idle or endurance result. Live provider-owned initialization writes have not yet been quantified. The previous Codex-plus-Claude idle comparison does not cover the restored Grok scope. + +## Implemented contract + +The opt-in Grok Beta appears in Settings, `All`, its detail destination, and the `Grok — Current-period usage remaining` menu picker. Its collector starts `grok agent --no-leader stdio` in a temporary empty working directory, sends `initialize` followed by `_x.ai/billing`, and reads CLI-version metadata from `_meta.agentVersion`. + +The entire fetch has a ten-second deadline. Its owned process group receives graceful termination, then forced cleanup after at most one second; cancellation waits for cleanup before another coordinated operation starts. Total stdout is capped at 1 MiB, stderr is discarded, and raw output is not persisted. + +Only a selected Grok menu metric schedules ten-minute automatic reads. Enablement, visible `All`/detail, and explicit actions may request a due read. Every launch respects a monotonic thirty-second floor. Automatic failures back off for 600, 1,200, 2,400, then at most 3,600 seconds; a successful read resets the backoff. Hidden, unselected Grok performs no polling. + +The latest snapshot is fresh for at most thirty minutes and expires at its known reset. A 64 KiB app-owned cache retains only the original finite used percentage, normalized period, optional provider-reported start, reset, observation time, measurement source, CLI version, and available plan/prepaid/PAYG/unified-pool facts. The snapshot has `0600` permissions and its app-owned parent has `0700`. A supplied start must be a supported finite date earlier than reset; a missing start remains absent and monthly boundaries are never invented. Missing optional facts remain absent. Current fields take precedence; legacy credit calculation is permitted only when both current fields are absent. A zero legacy limit is unavailable, while a present empty Cent object represents zero. Grok Build activity is not inferred from a shared usage pool. + +The authorized history extension retains actual Grok allowance observations in a separate local journal until explicit deletion. Weekly and monthly series remain distinct. Its active chart reads only a bounded view of the latest 84 days; the earlier latest-only cache can seed one real point, without reconstructing an earlier period. Estimates require at least two compatible points spanning at least sixty seconds, a fresh latest point before reset, and no gap over thirty minutes, reset, or correction. The [measurement contract](../MEASUREMENT-CONTRACT.md) defines the full history bounds and forecast rules. Claude's seven-day and five-hour observations use the same isolated history contract. + +Disable cancels collection and preserves the snapshot and history. `Delete Grok data…` disables collection and removes the app-owned snapshot, retained history, and executable preference; Grok Build’s files, settings, and login remain intact. + +The follow-up workspace update aligns Codex with the simpler Grok detail layout and adds current-period thumbnails to `All`. The thumbnails use the same step interpolation as full charts for actual observations, a dashed target, and the latest actual point; they omit estimates. Full Codex views and reset reminders remain available through `More`. Current-period Claude/Grok reads are bounded to 31 elapsed days / 32 UTC files, and only the compact display data remains resident in `All`. + +A second live Grok 1.0.25 probe on 2026-09-10 measured process I/O with macOS `proc_pid_rusage` v4. Across 49 samples through the successful billing response, the CLI reported 897,024 physical disk-write bytes and 1,552,384 logical-write bytes. The empty temporary working directory retained no files. Total probe time including cleanup was 1.735 seconds, child CPU was 0.211 seconds, peak child RSS was 80,003,072 bytes, and stdout was 4,349 bytes with no stderr. These counters measure the CLI through the reply, without reading credential files or retaining raw account output; they do not replace the idle comparison or lifecycle soak. + +## Validation and release exceptions + +The complete Release suite, including retained Grok/Claude history, shared burndown charts, and bounded current-period overview thumbnails, passed 644 tests with zero failures in 24.986 seconds. Coverage includes provider starts and old-cache migration, protocol/process cleanup, private daily journals and interrupted writes, bounded file-lock waits, overview/detail demand, deletion races, notification coalescing, forecast resets/corrections/gaps/source changes, missing current windows, and chart preference isolation. The release-validator self-check also passed. The Claude readiness-cancellation check now holds the shared coordinator and explicitly signals disable, eliminating its dependence on file-read duration; 30 consecutive local runs passed. Signed native packaging and a separate synthetic QA profile verified Grok’s weekly chart, Claude’s seven-day and five-hour charts, actual/target/current-estimate lines, point navigation, zoom, and isolated range preferences. Visible copy was checked for internal notes. Native QA caught and fixed an initial empty-view lifecycle issue that prevented asynchronous chart creation. The rebuilt user QA also displayed current-window thumbnails from the real Codex/Grok caches and the matching simplified Codex detail. Row navigation, `More`, Facts and reset reminders, and the collapsed usage details were checked in the signed native app; the prior QA profile and preferences were preserved. The synthetic profile was removed after validation. These checks and the earlier successful live Grok read do not prove the unperformed idle comparison or lifecycle soak. + +The [multi-integration PRD](../prd/multi-integration-workspace.md) is accepted for release 0.3.0 with explicit owner exceptions in [ADR-0015](../adr/0015-ship-claude-as-experimental.md). The live Claude Pro/Max observation is waived for the experimental integration; the expanded all-enabled idle comparison and eight-hour mixed lifecycle soak are waived specifically for 0.3.0. Neither performance check was run. OpenCode remains deferred. + +## Local testing + +1. Build with `Scripts/build-app.sh`, then run `open ".build/release/Codex Limits.app"`. +2. Enable `Grok` in Settings. Use `Locate…` if necessary; if sign-in is required, run `grok login` yourself and retry. +3. Select `Grok — Current-period usage remaining`. Check `All` and Grok detail for the returned period, reset, and CLI version. Optional fields should appear only when available. +4. Wait at least thirty seconds before `Refresh`; rapid repeated clicks must not launch another CLI. Closing the workspace and choosing another menu source should leave no recurring Grok read. +5. Disable Grok. It should disappear from navigation and, if selected, leave the menu metric as `None`. Re-enabling may reuse the cached snapshot, subject to freshness and reset expiry. +6. Check Grok's Usage remaining chart. An existing latest-only cache initially contributes only its actual point; later successful reads add observations. Relaunch should retain them, while `Delete Grok data…` removes them. A forecast stays unavailable until enough compatible fresh observations exist. Use deterministic fixtures for resets, corrections, and long gaps; do not turn private account readings into test fixtures. + +## Primary sources + +- [ACP extension request prefix](https://agentclientprotocol.com/protocol/v1/extensibility#custom-requests). +- [Official Grok agent mode](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/15-agent-mode.md). +- [Official billing handler and response types](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/extensions/billing.rs), inspected September 10; source tree `SOURCE_REV` reported `c4ea71cfdbcdb21e32e41bc25a0043d7d4836714`. +- [Official ACP router](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs). +- [Official shared-pool explanation](https://docs.x.ai/grok/faq). diff --git a/docs/research/multi-integration-v1-validation-spikes-2026-08-22.md b/docs/research/multi-integration-v1-validation-spikes-2026-08-22.md new file mode 100644 index 0000000..a6912d4 --- /dev/null +++ b/docs/research/multi-integration-v1-validation-spikes-2026-08-22.md @@ -0,0 +1,200 @@ +# Multi-integration v1 validation spikes + +> **Correction — 2026-09-10:** The Grok exclusion and Grok-specific no-source claims below are superseded. The August probe used bare `x.ai/billing`; ACP requires `_x.ai/billing` on the wire. Official stable 1.0.25 passed a real authenticated read with the corrected method. The historical 1.0.5 prefix case remains untested. Grok is now a development Beta; OpenCode remains deferred. The original Codex-plus-Claude idle result does not cover Grok, and the expanded all-enabled comparison, provider-owned Grok write measurement, eligible Claude observation, and eight-hour soak remain release work. See [current Grok validation](grok-build-validation-2026-09-10.md). Original evidence follows unchanged. + +Date: 2026-08-22\ +Status: Completed for this machine; Claude release gates remain + +## Purpose + +These spikes validate the smallest supported collection path for Claude Code, Grok, and OpenCode before production implementation. They use original fixtures and isolated local processes. No CodexBar source code was copied. CodexBar informed the list of questions to test, while provider-owned documentation and binaries define the contract. + +## Verdict + +| Integration | Result | v1 consequence | +|---|---|---| +| Claude Code | Implementation pass; release gates remain | Event-driven source, UI, exact setup/removal, and bounded relay are implemented; finish eligible-account and lifecycle release checks | +| Grok | No-go for the stable 1.0.5 ACP collector | `initialize` works but `x.ai/billing` returns `-32601`; defer from v1 without credential/private-backend fallbacks | +| OpenCode | No-go for the server collector | Defer OpenCode from v1 unless a supported, materially lighter read-only source is found | + +## Claude Code 2.1.92 with 2.1.231 continuation + +### Environment and checks + +- Selected binary: `/opt/homebrew/bin/claude`, version `2.1.92`. +- The real user settings file was read only for structural metadata. It was valid JSON, contained only `model` and `permissions`, and had no `statusLine` entry. No command, permission value, credential, or other setting value was printed or persisted. +- No project or project-local Claude settings file existed in this repository at test time. +- The installed CLI accepts both `--settings ` and `--setting-sources `. +- A synthetic settings file containing a command `statusLine` loaded successfully through `claude --settings ... --version`. No Claude session, prompt, authentication, or network request was started. +- An original fixture parser accepted independently optional five-hour and seven-day windows, converted used percentage to remaining percentage, rejected values outside `0...100`, rejected input larger than 256 KiB, and omitted the fixture's `session_id` and `transcript_path` from its normalized result. +- A settings collision check accepted a document without `statusLine` and refused a document with an existing user-owned `statusLine`. + +Anthropic's supported status-line contract exposes `rate_limits.five_hour` and `rate_limits.seven_day`, but only for eligible Claude.ai subscribers after the first response; either window may be absent. It is event-driven and can also be configured with an optional periodic refresh, which Codex Limits must not add. See [Customize your status line](https://code.claude.com/docs/en/statusline). + +### Constraints not removed by the spike + +- Setup changes Claude Code's visible footer and runs a local command during Claude activity. It therefore remains a user-confirmed action in Settings, never automatic discovery. +- Codex Limits must not overwrite or wrap a user-owned `statusLine` in the user settings file. It does not scan project, local-project, or managed settings; those higher-precedence scopes remain untouched and can override the installed user status line. +- The app cannot force fresh allowance data without generating Claude activity, so the UI must say `Last observed` and never simulate a refresh with a dummy prompt. +- Release still needs one user-confirmed observation on an eligible Claude account plus the eight-hour lifecycle soak. The eligible-account check must not use a dummy prompt or change the user's existing setup without confirmation. +- A continuation check found Claude Code `2.1.231` installed through the official Homebrew cask. It reported no active login in the Codex execution context. Anthropic documents that the Free plan does not include Claude Code allowance access and that status-line `rate_limits` appear only for Pro/Max subscribers after a response, so this machine cannot satisfy the eligible-account allowance gate without a different eligible account. Installation alone is still sufficient for deterministic setup and relay tests. + +### Eligible-account release observation + +This check can be completed later by one consenting Pro or Max tester; it does not require changing this machine's Free account. The tester enables Claude Code in Settings, reviews the footer consequence, and completes setup only when no user-owned status line conflicts. They then continue a Claude Code session with a response they already intended to request; Codex Limits must not create a dummy prompt. The gate passes when at least one five-hour or seven-day window reaches the app-owned allowlisted cache, renders as `Last observed`, and a manual check performs only a cache read. Disabling must stop writes and remove only the exact Codex Limits-owned status-line entry. Evidence records the Claude Code version, window presence, normalized percentages and resets, observation time, file permissions, and semantic before/after settings comparison; it records no account identity, session or model metadata, prompt, response, transcript, project path, credential, or raw event. + +### Decision + +The source is feasible and naturally cheap: Claude pushes a bounded event to a short-lived relay, while the app reads its own tiny normalized cache. The implementation now includes device-local enablement, menu metric selection, `All` and detail surfaces, exact setup/deactivation, app-owned data deletion, independent five-hour and seven-day lifetime handling, and one shared priority-aware source-work coordinator with Codex. + +### Implemented relay and package checks + +- Seven relay tests cover allowlist privacy, invalid and oversized input, out-of-order writers, equivalent-write suppression, tampered cache rejection, disabled-marker behavior, and a neutral non-empty footer when an event contains no eligible allowance. +- Thirteen setup/lifetime tests cover exact install/removal when user settings exist or are initially absent, settings added after setup, user-owned conflict preservation, modified configuration preservation, app-owned data deletion, suppression of an in-flight readiness result after disable, zero hidden/unselected launch reads until visible demand, QA path isolation, explicit executable validation, compatible snapshot preservation during an app-helper update, and independent reset behavior. +- The packaged helper passed strict bundle signature verification and remained executable inside `Contents/Helpers`. +- A later Release QA package check verified the isolated bundle identifier, the app and nested helper signatures, and matching arm64 architectures. The packaged helper produced `7d 60% remaining` from an original bounded fixture, wrote only the allowlisted `0600` snapshot fields, and produced `Usage unavailable` without a cache for an event with no eligible allowance. +- The universal release workflow now separately requires an executable, strictly signed `CodexLimitsClaudeRelay` containing both arm64 and x86_64 slices. The release validator refuses to proceed while the multi-integration PRD status remains `Needs revision`. +- On a synthetic event containing private session and model fields, the packaged helper emitted the expected status line, wrote a 179-byte `0600` cache containing only `version`, `observedAt`, `cliVersion`, `fiveHour`, and `sevenDay`, and did not rewrite an equivalent event inside 30 seconds. +- `/usr/bin/time -l` measured 7,159,808 bytes maximum RSS, 2,294,192 bytes peak memory footprint, and a wall time rounded to 0.00 seconds for that bounded event. +- A disabled-default UI QA checkpoint used 132,016 KiB RSS at 0.0% CPU after 73 seconds and had no app-owned child process. This was a short control before the completed comparison below and is not the eight-hour soak. + +## Grok + +The protocol/source distinction and exact runtime evidence are recorded in the [Grok ACP contract/runtime note](grok-build-acp-runtime-contract-2026-08-22.md). + +### Environment and checks + +- The initial restricted shell snapshot did not resolve `grok`; the continuation check found the user's working installer-managed executable at `/Users/piotrciechowicz/.grok/bin/grok`. No credential or auth file was read. +- On the continuation check, xAI's official `stable` channel returned version `1.0.5`. The installer documents both `https://x.ai/cli` and `https://storage.googleapis.com/grok-build-public-artifacts/cli` as artifact origins. +- The macOS arm64 1.0.5 binary was downloaded separately from both origins into `/private/tmp`, never installed or added to `PATH`. Both files were bit-for-bit identical: 134,349,648 bytes, SHA-256 `3dfa7f04fbb5427a8fbead286591543aaecb478b3a0ab222c4329eca1a3b2f86`. +- Static inspection reported a thin arm64 Mach-O with hardened-runtime metadata, identifier `xai-grok-pager`, and Team ID `5Y6N3AJ54S`. Apple's strict verifier rejected the two official, byte-identical copies even though the matching installed CLI executes normally; this standalone-package diagnostic is not an installation, auth, or subscription failure. +- The binary was not executed, no authentication command was invoked, and `~/.grok` remained unread and unchanged. The two temporary copies were deleted after recording the result. +- A continuation check found `/Users/piotrciechowicz/.grok/bin/grok` symlinked to the installer-managed download. It is stable `1.0.5`, has the same size and SHA-256 as both isolated downloads, and successfully executed `--version` and the bounded `doctor` diagnostic on macOS 26.5.2. +- The current official Homebrew Claude Code `2.1.231` binary independently fails the same strict `codesign` verification while executing normally. For these standalone CLI distributions, strict signature verification is therefore retained as diagnostic evidence but removed as a sole provenance or execution gate. +- An account query from the restricted Codex execution context was inconclusive: network access was unavailable and no usable Grok authentication was exposed in that context. It does not test the user's normal terminal session or subscription. No prompt or billable model request was sent. +- A fresh isolated home then ran the documented sequence correctly in ordinary mode and repeated it with the source-recommended `grok agent --no-leader stdio` mode. Both waited for `initialize` before sending `x.ai/billing` with a literal slash and empty parameters. `initialize` returned protocol and capability metadata; billing returned JSON-RPC error `-32601 Method not found` before authentication could matter. +- The final isolated `--no-leader` run used 51,363,840 bytes maximum child RSS and 0.179 seconds combined child user/system CPU. It emitted 3,614 bytes on stdout and 122 bytes on stderr, below the output bounds. Startup plus the one-second shutdown boundary took 1.527 seconds. +- Closing stdin did not produce graceful exit inside one second, so the harness terminated the complete process group. It was fully reaped with no surviving process. The isolated home contained 34 provider initialization files totaling 613,539 bytes; the temporary directory was then deleted. +- The data contract was pinned to xAI's provider-owned source at commit [`19d42e35`](https://github.com/xai-org/grok-build/blob/19d42e35c07a9c9244f03f6df0c4c353f970d4f9/crates/codegen/xai-grok-shell/src/extensions/billing.rs). That source defines the `x.ai/billing` extension, prefers `creditUsagePercent` plus `currentPeriod`, and retains deprecated `monthlyLimit`, `used`, and billing-period fields for compatibility. +- Original current-shape and legacy-shape fixtures normalized successfully. The fixture check rejected an unknown period type, a zero legacy limit, an invalid reset, and values outside the percentage/limit range. +- The current shape keeps weekly and monthly periods distinct. No unknown period is relabeled. + +### Rejected source and future entry conditions + +- Stable 1.0.5 does not expose billing on its supported external ACP route. The source implementation is used by the pager/TUI, but source presence does not make it callable by an external app. +- Codex Limits intentionally rejects the remaining known fallbacks: reading `~/.grok/auth.json`, reusing browser credentials, calling the private Grok billing backend, or scraping/parsing the interactive `/usage` TUI. +- Grok can return only when an official supported release exposes a read-only external billing method. That release must then pass authentication, personal/team behavior, zero-value ambiguity, timeout, cancellation, output, RSS, source-mutation, and complete cleanup gates. +- Executable identity validation belongs to explicit setup or detected replacement only. Ordinary refresh must never hash the 134 MB binary. + +### Decision + +The decoder contract remains useful future work and the local executable is viable, but the only accepted external source is absent from stable 1.0.5. Defer Grok from shipped v1. Do not implement a production process wrapper around a method that deterministically returns `Method not found`, and do not replace it with credential or private-backend access. A strict signature failure remains diagnostic evidence but is not the reason for deferral. + +## OpenCode 1.18.11 + +### Isolation + +- Selected binary: `/opt/homebrew/bin/opencode`, version `1.18.11`. A second user binary at `~/.opencode/bin/opencode` reported `1.15.12` but was not started because the supported newer binary already failed the resource gate. +- `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `XDG_STATE_HOME` pointed to a dedicated temporary directory. `HOME` was not changed. Real OpenCode session storage was not read or modified. +- The server used `--pure`, loopback-only binding, and Basic Auth. The password was test-only and is not recorded. + +### Functional result + +- `serve --help` advertises `--pure`, hostname `127.0.0.1`, disabled mDNS, and a default port of `0`. +- At runtime, `--port 0` listened on fixed port `4096`, not an operating-system-assigned random port. +- `/global/health` returned healthy with version `1.18.11`. +- Three empty synthetic sessions were created in the isolated database. `GET /session?limit=2` returned only the two newest sessions, proving a server-side count bound on this version. +- Session summaries included cumulative `cost` and token categories for input, output, reasoning, cache read, and cache write. Empty sessions correctly contained zeroes; their aggregation semantics were not proven. +- The server stopped on interrupt and no residual OpenCode process remained. + +### Resource result + +| Observation | Measured result | +|---|---:| +| Server process RSS after health/session requests | 754,240 KiB, approximately 736 MiB | +| CPU at the sampled instant | 0.3% | +| Isolated working-set files created | 3,654 | +| Isolated disk footprint | 67,316 KiB | +| `config/opencode/node_modules` | 62,632 KiB | +| `cache/opencode/models.json` | 4,168 KiB | + +These writes occurred despite `--pure`; that option does not mean read-only or zero-initialization. The RSS exceeds the provisional per-Integration app budget by roughly two orders of magnitude, and the fixed port prevents safe concurrent random-port startup as specified. + +### Decision + +The short-lived local-server design is rejected for v1. A 736 MiB child process is user-visible system load even if it is terminated correctly. Do not implement periodic or launch-time OpenCode collection using this method. + +OpenCode can return to scope only if an official supported interface supplies bounded session summaries with cost/token fields without loading the server runtime, or if a supported later release is measured to fit the same release budgets. `opencode session list --format json` is lighter but currently lacks the required cost/token facts; reading the internal SQLite database remains unsupported and is not an acceptable fallback. + +## Codex performance baseline and bounded working set + +### Environment + +- Release build on an Apple M1 Pro with 32 GiB RAM. +- macOS 26.5.2, build 25F84. +- The QA build used an isolated bundle identifier and defaults suite. The installed production app remained running and was not modified or stopped. +- Process RSS and CPU were sampled from the operating system. Synthetic history fixtures contained no user data. + +### Baseline before hardening + +| Observation | Result | +|---|---:| +| QA parent, 60 samples every 10 seconds | 123,910.1 KiB average RSS; 124,192 KiB maximum; 0.0000% average CPU | +| QA `codex app-server --stdio` child at the end of the trace | 98,704 KiB RSS | +| QA parent and child combined | 222,720 KiB, approximately 217.5 MiB | +| Installed production parent and child at a comparable idle sample | 215,472 KiB, approximately 210.4 MiB | + +The parent was CPU-idle, but the app-server remained alive between ten-minute account refreshes. Correctly idle CPU did not justify keeping roughly another 96 MiB resident for passive analytics. + +### Implemented bounds and measured result + +- The Codex protocol connection is still reused for related requests in one burst, then closes after five seconds without protocol work. A focused unit test proves that immediate reads reuse one initialized session and a read after the idle boundary creates a new session. +- The Analytics History reader keeps at most 6,000 samples from the latest 84 days. The latest eight days retain full resolution; older observations retain the first and last point per hourly/reset bucket and explicit comparison breaks. +- A ten-year fixture retained all 3,650 canonical daily files while publishing 85 reader samples. In the earlier 583-test Release run, the cold working-set load was 10.206 ms; a disconnected automatic refresh used the in-memory state and took 0.007 ms. A separate dense fixture stopped at exactly 6,000 samples. +- In a quick Release validation 23 seconds after launch, the QA parent used 122,208 KiB RSS at 0.0% CPU and had no child process. The installed production build still had its old persistent child, demonstrating that the disappearance was specific to the hardened QA build rather than an operating-system-wide event. +- Across the subsequent ten-minute Release trace, 60 samples taken every ten seconds averaged 122,056.5 KiB RSS with a 122,288 KiB maximum. Average and maximum sampled CPU were both 0.0000%; no child process appeared in any sample. Against the earlier combined QA baseline, steady idle RSS fell by approximately 98.3 MiB. +- After restoring complete sync semantics for the bounded-working-set slice, an idle check measured 122,032 KiB RSS at 0.0% CPU with no child process. +- The final QA source, including manifest/cursor sync and explicit older-range access, measured 124,656 KiB RSS at 0.0% CPU after 19 seconds with no child process. That QA instance was then stopped; the installed production process and its existing child were left untouched. + +The spike rejected reading only the newest files during sync because it can silently omit older observations created while the shared folder is unavailable. The implemented ADR-0014 replacement uses a small writer manifest, a relaunch-safe round-robin cursor, recent-change priority, and at most 32 calendar-day candidates and daily-file reads per periodic pass. It also avoids rewriting unchanged merged days, so an idle pass does not create artificial revisions or disk churn. + +The separate older-range reader accepts only an explicit interval no longer than 84 days, reports retained and covered bounds, returns at most 6,000 ordered samples, marks the result `exact` or `downsampled`, and checks cancellation between files. It does not add those samples to the default resident state. + +### Test-health boundary + +- `CodexClientTests` passed 43 of 43 tests before the idle change and 44 of 44 after adding its focused lifecycle test. +- The first baseline exposed 42 `UsageHistoryTests` failures among 49 tests. They shared one root cause: `updateMarkerAtomically` invoked `NSFileCoordinator` for ordinary local folders, where coordinated replacement plus atomic writing failed with Cocoa error 512. The shared function now uses an in-process lock plus atomic write for non-iCloud markers and retains `NSFileCoordinator` for ubiquitous locations. +- After adding working-set, range, cursor, convergence, malformed-repair, and no-op-write coverage, `UsageHistoryTests` passed 56 of 56. The concurrent-generation regression passed 20 repeated Debug runs. +- The complete pre-Grok-runtime Release suite passed 583 tests with zero failures and zero unexpected failures in 23.252 seconds. It includes the serialized-work, disabled-source, Claude relay, setup, lifetime, deletion, and QA-path isolation checks. +- After the Grok runtime no-go narrowed the shipped enum and menu metric set, review also found that explicit history refresh still selected the complete reconciliation path. Explicit and automatic refresh now share the 32-candidate bound; only a user-confirmed first folder connection may reconcile completely. A final PRD-to-code scheduling audit then found that the Codex timer remained armed when Claude or `None` supplied the menu metric. The timer now exists only while Codex supplies that metric; launch, wake, re-enable, and automatic refresh remain source-silent otherwise, while `All` and Codex detail can request a serialized due read. The Claude lifecycle audit added generation checks after every asynchronous setup, inspection, cache-read, disable, and deletion boundary so a superseded result cannot publish. It also removed hidden/unselected launch and notification cache reads: Claude now reads only for Settings, enablement, menu selection, or visible `All`/detail demand, and closing the workspace clears visible demand. The final completeness audit added device-local `Locate…` for Codex and Claude without a login shell or CLI launch, a useful Free-plan footer fallback, compatible-cache preservation during helper update, full menu accessibility values, source-free Freshness/reset boundary transitions, and a visible-demand-only `Earlier` history action with fixed cold-read ceilings. The complete Release suite passed 601 tests with zero failures and zero unexpected failures in 26.632 seconds. Regressions cover deferred Integration persistence, bounded explicit and automatic history reconciliation, cold-read writer/file/byte bounds, older-range cancellation, exact Claude settings removal and preservation, in-flight disable ordering, executable selection, reset expiry without another source read, and demand-driven Codex and Claude scheduling. That run measured the ten-year cold working-set load at 12.169 ms and disconnected automatic refresh at 0.007 ms. +- The live Codex protocol deadline now matches the PRD's ten-second maximum; the five-second idle-release boundary remains separate. +- `Scripts/measure-app-idle.sh --self-test` passed. The release comparison runs the sampler for 2,400 seconds at ten-second intervals in each state, discards the first 60 warm-up samples, and evaluates the final 180 samples. Its CSV includes parent and direct-child RSS, CPU, and child count. +- The QA app now constructs Claude setup paths entirely beneath its isolated Application Support directory and uses its packaged relay as the non-invoked availability fixture. All thirteen `ClaudeCodeSetupServiceTests` passed in the final focused Release run. +- A signed Release QA smoke test completed the visible enable, consent, setup, waiting-for-data, and disable flow. The QA fixture settings gained and then removed only the expected status line; its enabled marker and install record were removed on disable. The real `~/.claude/settings.json` retained the same SHA-256, size, permissions, and modification time before and after. Original QA defaults and data were restored, and generated test data was deleted. + +### Completed 30-minute idle comparison + +The Release QA build ran twice from the same restored defaults and an empty isolated QA data directory. The installed production app stayed running and was not modified. The first profile enabled only Codex; the second enabled Codex and Claude Code. Both selected the Codex weekly metric and kept Settings closed. Claude setup, authentication, the real Claude configuration, and real Claude data were not touched. + +| Final 180 samples after warm-up | Codex only | Codex + Claude Code | Regression | +|---|---:|---:|---:| +| Average parent RSS | 110,072.622 KiB | 110,000.444 KiB | -72.178 KiB | +| Maximum parent RSS | 110,128 KiB | 110,112 KiB | -16 KiB | +| Final minus first parent RSS | +48 KiB | +48 KiB | 0 KiB | +| Average parent CPU | 0.000000% | 0.001111% | +0.001111 percentage points | +| p95 parent CPU | 0.0% | 0.0% | 0.0 percentage points | + +The RSS gate permits a 10 MiB increase and the CPU gate permits a 0.2-percentage-point increase, so both passed with substantial margin. No child was retained at the end of either run. Short Codex app-server bursts appeared at the existing 600-second account-refresh cadence: one sampled burst in the Codex-only trace and three in the Codex-plus-Claude trace. The Claude implementation has no recurring collection timer and starts no provider child process; the all-enabled trace showed no child activity outside those Codex refresh boundaries. The original QA defaults and data directory were restored after both runs. + +The eight-hour mixed lifecycle soak remains a release gate. The completed 30-minute comparison does not substitute for it. + +The PRD now defines an exact 490-sample soak protocol, hourly lifecycle cycle, settling boundary, checkpoint evidence, and numerical pass criteria. The full run still requires user-confirmed real network-unavailable/recovery and sleep/wake events; synthetic events or unapproved system changes are not accepted as substitutes. + +## Resulting implementation order + +1. Measure and bound the existing Codex default reader, explicit older ranges, and eventual sync. **Completed.** +2. Implement the event-driven Claude Code path and its exact setup/uninstall safety. **Completed; long release measurements remain.** +3. Keep Grok out of the shipped v1 collector until an official external billing source appears. +4. Keep OpenCode out of the shipped v1 collector until its source gate changes. + +The Codex baseline, bounded history, shared Integration state, Claude implementation gates, and 30-minute idle comparison are now recorded and implemented. Grok and OpenCode are explicitly outside v1 rather than unresolved implementation items. The multi-integration PRD remains `Needs revision` because the eligible-account Claude observation and eight-hour soak remain release gates. diff --git a/docs/research/multi-vendor-integration-feasibility-2026-08-18.md b/docs/research/multi-vendor-integration-feasibility-2026-08-18.md new file mode 100644 index 0000000..6852eef --- /dev/null +++ b/docs/research/multi-vendor-integration-feasibility-2026-08-18.md @@ -0,0 +1,387 @@ +# Rozszerzenie Codex Limits o Claude Code, Grok/xAI i OpenCode + +**Stan researchu:** 2026-08-18 + +**Zakres:** architektura bieżącego repozytorium, oficjalne kontrakty CLI/API oraz rekomendowany zakres produktu + +**Metoda:** analiza lokalnego kodu i testów oraz źródeł pierwotnych Anthropic, xAI i OpenCode; konkurencja tylko jako punkt odniesienia + +> Aktualizacja runtime 2026-08-22: ten dokument jest analizą wstępną. Późniejszy spike wykazał, że Grok Build stable 1.0.5 nie wystawia `x.ai/billing` przez zewnętrzne `grok agent stdio` (`-32601 Method not found`), mimo że handler istnieje w źródle pagera/TUI. OpenCode `serve --pure` zużył około 736 MiB RSS i wykonał około 66 MiB zapisów inicjalizacyjnych. Obie integracje są dlatego odroczone poza v1; obowiązują [wyniki walidacji](multi-integration-v1-validation-spikes-2026-08-22.md), nie pierwotne rekomendacje implementacyjne poniżej. + +## Werdykt + +Da się rozszerzyć produkt, ale nie przez dodanie jednego ogólnego `Vendor` z trzema nowymi wartościami. Obecny Codex pełni jednocześnie trzy niezależne role: + +1. źródła autorytatywnego limitu konta; +2. źródła lokalnej aktywności coding agenta; +3. silnika funkcji `Analyze with Codex`. + +Claude Code, Grok/xAI i OpenCode mają różne pokrycie tych ról. Najlepszy, najmniej ryzykowny plan to: + +1. wydzielić tylko wspólną tożsamość integracji, odczyt okien allowance i partycjonowanie historii; +2. dodać **Claude Pro/Max (local beta)** jako ostatnio zaobserwowane okna 5 h i 7 dni z oficjalnego `statusLine`; +3. dodać **OpenCode Local Activity**, bez udawania limitu konta; +4. zrobić ograniczony spike **Grok Build billing przez `x.ai/billing` ACP**, a po potwierdzeniu kilku wersji CLI udostępnić go jako beta; +5. traktować xAI API billing, Claude Organization i późniejsze silniki analizy jako oddzielne integracje. + +Najważniejsze rozróżnienie produktowe: + +- **Claude Code** może dostarczyć użycie subskrypcji, ale tylko jako zdarzeniowy, ostatnio zaobserwowany stan aktywnego CLI. +- **Grok Build** ma odczyt billingowy przez oficjalny CLI, lecz na niewersjonowanym rozszerzeniu ACP. +- **OpenCode** jest przede wszystkim agentem i agregatorem wielu providerów, a nie jednym providerem kontowym. Najlepiej nadaje się do lokalnych sesji, tokenów i szacowanego kosztu. + +Jeżeli celem jest wyłącznie „pokaż wszystkie paski limitów w menu bar”, nie budowałbym tego tutaj. [CodexBar](https://github.com/steipete/CodexBar) już obsługuje Claude, Grok i OpenCode/OpenCode Go, jest aplikacją macOS na licencji MIT i ma znacznie szerszy katalog providerów. Własna implementacja ma sens tylko wtedy, gdy zachowujemy wyróżnik Codex Limits: provenance danych, Coverage/Confidence, lokalne Task Trees, Usage Receipts i ostrożne prognozy. + +## 1. Dlaczego obecny kod nie jest jeszcze wielovendorowy + +### 1.1. Konto i allowance + +[`CodexClient.swift`](../../Sources/CodexLimits/CodexClient.swift) uruchamia wyłącznie Homebrew Codex z dwóch stałych ścieżek i łączy się przez `codex app-server --stdio`. Jedno odświeżenie uzgadnia: + +- `account/rateLimits/read`; +- `account/usage/read`; +- `account/read`; +- aktualizacje push stanu konta. + +Kod wybiera okno dokładnie `10080` minut jako główny limit. [`UsageModels.swift`](../../Sources/CodexLimits/UsageModels.swift), [`UsagePerToken.swift`](../../Sources/CodexLimits/UsagePerToken.swift) i [`MEASUREMENT-CONTRACT.md`](../MEASUREMENT-CONTRACT.md) również traktują tygodniowy allowance Codex jako podstawową domenę produktu. + +[`UsageMonitor.swift`](../../Sources/CodexLimits/UsageMonitor.swift) ma przydatny szew — wstrzykiwane `fetchUsage` — ale utrzymuje jeden snapshot konta, jedną partycję historii, jeden kolektor lokalny i jeden zestaw prognoz. Brakuje identyfikatora źródła w próbkach oraz historii. + +### 1.2. Lokalna aktywność + +[`LocalActivityCollector.swift`](../../Sources/CodexLimits/LocalActivityCollector.swift) i parsery obok niego zakładają: + +- katalog `~/.codex/sessions`; +- Codex rollout JSONL; +- projekcje `thread/list` i `thread/read`; +- zamknięty zestaw source kinds związanych z Codex; +- semantykę Codex task, turn, agent, compaction i token counters. + +Normalizowane fakty są częściowo użyteczne dla innych agentów, ale discovery i wire format są całkowicie Codex-specific. Nowe źródła powinny mieć własne małe adaptery, a nie warunki `if vendor == ...` w parserze rolloutów. + +### 1.3. Assisted Insights + +[`CodexAssistedInsights.swift`](../../Sources/CodexLimits/CodexAssistedInsights.swift) jest osobnym, dużym przepływem opartym o Codex App Server: discovery modeli, izolowany thread, read-only sandbox, schema wyniku, przerwanie turnu oraz pomiar allowance przed i po analizie. + +To już ma protokół serwisowy, więc później można dodać inny runner. Nie jest to jednak konieczne do dodania limitów lub lokalnej aktywności i nie powinno blokować pierwszych integracji. + +### 1.4. Skala realnej zmiany + +Najbardziej związane obszary mają obecnie około: + +- 4,5 tys. linii w głównym widoku menu; +- 2,3 tys. linii w Assisted Insights; +- 1,9 tys. linii w lokalnym collectorze; +- 1,3 tys. linii w `UsageMonitor`; +- 25,6 tys. linii testów Swift. + +To oznacza, że „neutralizacja nazw” nie jest właściwym pierwszym krokiem. Potrzebna jest mała granica danych i migracja historii, przy zachowaniu działającej ścieżki Codex bez przepisywania jej. + +## 2. Macierz możliwości + +| Integracja | Limit subskrypcji / reset | Lokalna aktywność | Billing API | Runner analizy | Ocena pierwszego wdrożenia | +|---|---|---|---|---|---| +| Codex | Pełny, odczyt live z App Server | Pełne lokalne taski i tokeny | Fakty konta przez App Server | Obecny | istnieje | +| Claude Pro/Max | 5 h i 7 dni przez `statusLine`; last observed | OTel lub lokalne sesje, osobny etap | Nie dla indywidualnego planu | Tylko BYO API key/chmura bez zgody na OAuth planu | łatwe–średnie | +| Claude Organization | Nie jest allowance Pro/Max | Analytics organizacyjne | Usage & Cost / Analytics API | API key | średnie–trudne | +| Grok Build / SuperGrok | `x.ai/billing` przez oficjalny CLI; kontrakt niewersjonowany | Sesje na dysku i opt-in OTel | Nie publiczny consumer REST | Oficjalny headless/ACP | średnie, beta | +| xAI API | Rate limits przepustowości, nie weekly allowance | Tylko aktywność wykonana przez dany klient | Management API: usage, saldo, spend limits | Responses API | łatwe–średnie | +| OpenCode generic | Brak wspólnego limitu providerów | Bardzo dobre session totals przez lokalny server | Brak wspólnego billing ledger | `opencode run`/server, ale deleguje do providera | łatwe dla Local Activity | +| OpenCode Go | Wewnętrzny endpoint ma rolling/weekly/monthly, ale nie jest publicznym kontraktem | Jak OpenCode | Console | Jak OpenCode | odłożyć | + +## 3. Claude Code + +### Co jest oficjalnie dostępne + +Claude Code uruchamia skonfigurowany przez użytkownika skrypt `statusLine` i przekazuje mu JSON przez stdin. Udokumentowany schemat zawiera: + +- `rate_limits.five_hour.used_percentage` i `resets_at`; +- `rate_limits.seven_day.used_percentage` i `resets_at`; +- model, session ID, wersję CLI; +- kontekst, tokeny bieżącego wywołania i szacowany koszt sesji. + +Pola `rate_limits` pojawiają się dla subskrybentów Claude.ai Pro/Max po pierwszej odpowiedzi API; każde okno może być osobno nieobecne. Skrypt jest wywoływany zdarzeniowo, więc dane są ostatnio zaobserwowanym stanem, a nie gwarantowanym odczytem live. Źródło: [Claude Code status line](https://code.claude.com/docs/en/statusline). + +Claude ma też oficjalny opt-in OpenTelemetry z tokenami, kosztami, sesjami, aktywnym czasem, agentami i narzędziami. To lepsza powierzchnia dla bogatej lokalnej telemetrii niż parsowanie zmiennego transcriptu, ale wymaga konfiguracji exportera i lokalnego OTLP receivera. Źródło: [Claude Code monitoring](https://code.claude.com/docs/en/monitoring-usage). + +Tryb headless `claude -p` ma JSON/stream-json, structured output, usage oraz koszt. Jest dobry dla jawnie uruchamianej funkcji analizy, lecz nie dla odświeżania limitu: dummy prompt zużywa allowance i uruchamia agenta. Źródło: [Claude Code headless mode](https://code.claude.com/docs/en/headless). + +Administracyjne Usage & Cost oraz Claude Code Analytics są właściwe dla organizacji, nie dla osobistego Pro/Max. Źródła: [Usage & Cost Admin API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api), [Claude Code Analytics API](https://platform.claude.com/docs/en/manage-claude/claude-code-analytics-api). + +### Minimalny wariant + +**Claude Pro/Max (local beta):** + +1. użytkownik sam instaluje i loguje oficjalne CLI; +2. opt-in konfiguruje mały relay `statusLine`; +3. relay zapisuje atomowo tylko allowlistę: czas obserwacji, wersję CLI oraz oba okna użycia/resetu; +4. aplikacja pokazuje `seven_day` jako główne okno, `five_hour` jako dodatkowe; +5. UI zawsze pokazuje „last observed” i wiek próbki; +6. brak lub przeterminowanie danych jest stanem `unavailable/stale`, nigdy `0% used`; +7. istniejący `statusLine` nie jest automatycznie nadpisywany; +8. historia pozostaje lokalna, dopóki nie ma wspieranego identyfikatora konta. + +### Co jest łatwe, a co trudne + +| Element | Ocena | Powód | +|---|---:|---| +| Parse dwóch okien | łatwe | Jawny i prosty schemat JSON. | +| Mapowanie used → remaining | łatwe | `remaining = 100 - used`, z walidacją 0–100. | +| Cache i oznaczenie świeżości | łatwe–średnie | Atomowy zapis, out-of-order events, kilka aktywnych sesji. | +| Bezpieczny setup/uninstall relay | średnie | Jeden slot `statusLine` może być już używany. | +| Pełna lokalna telemetria OTel | średnie–trudne | Receiver, protobuf/HTTP lub gRPC, retry, deduplikacja i privacy. | +| Odczyt live bez aktywnego Claude | niewykonalne pasywnie | Brak indywidualnego read-only usage endpointu. | +| Stabilna historia wielu kont | trudne | `statusLine` nie daje trwałej tożsamości konta. | +| `Analyze with Claude` przez plan Pro/Max | no-go bez zgody | Produkt zewnętrzny nie powinien kierować własnych żądań przez OAuth/allowance użytkownika; użyć BYO API key lub chmury. | + +Przed użyciem Agent SDK w produkcie trzeba też uwzględnić [warunki i compliance Claude Code](https://code.claude.com/docs/en/legal-and-compliance); nie należy bundlować CLI tylko dlatego, że jego repozytorium jest publiczne. + +## 4. Grok Build i xAI + +### 4.1. Konsumencki allowance Grok Build + +Oficjalny consumer FAQ opisuje wspólny procentowy pool planów SuperGrok, używany przez różne produkty, z procentem, breakdownem i resetem w Settings → Usage. To nie jest to samo co teamowe xAI API prepaid/postpaid. Źródło: [Grok FAQ](https://docs.x.ai/grok/faq). + +Najważniejsze odkrycie: oficjalne, otwartoźródłowe Grok Build implementuje custom ACP request `x.ai/billing`. W obecnym kodzie: + +- handler pobiera authenticated Grok Build billing bez ujawniania credentials klientowi; +- preferuje `creditUsagePercent` i `currentPeriod`; +- `currentPeriod.type` rozróżnia weekly/monthly, a `start`/`end` są RFC 3339; +- odpowiedź może zawierać prepaid balance, PAYG i subscription tier; +- pager Grok sam wywołuje tę metodę i mapuje wynik do własnego widoku `/usage`. + +Źródła pierwotne, przypięte do badanego commita `d71f6e0`: + +- [`x.ai/billing` i schema response](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-shell/src/extensions/billing.rs); +- [wywołanie ACP przez pager](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/app/effects/mod.rs); +- [mapowanie current period i percentage](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs); +- [etykiety weekly/monthly i summary](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/views/credit_bar.rs). + +To jest dużo lepsze niż scraping grok.com: aplikacja uruchamia user-managed `grok agent stdio`, inicjalizuje ACP i prosi oficjalny CLI o billing. Nie czyta `~/.grok/auth.json`, cookies ani prywatnych tokenów. + +Ryzyko: `x.ai/billing` jest oficjalnym kontraktem implementacyjnym, lecz nie publicznym, wersjonowanym REST API. Ogólna dokumentacja ACP mówi, że przestrzeń `x.ai/*` może się rozszerzać i jest SpaceXAI-specific; sama metoda billing nie ma osobnej publicznej specyfikacji. Integracja powinna więc mieć capability probe, tolerant decoder, fixtures per wspierana wersja CLI i czytelny fallback `Grok billing unavailable`. + +### 4.2. Lokalna aktywność Grok Build + +Grok Build przechowuje sesje w `~/.grok/sessions///`. Oficjalny guide opisuje m.in. `summary.json`, `updates.jsonl`, `signals.json` oraz subagents. `updates.jsonl` jest autorytatywnym strumieniem ACP, ale niesie także treść i narzędzia. Źródło: [Grok Build sessions](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md). + +Bezpieczne minimum może czytać tylko allowlistę z `summary.json` i niesensytywne agregaty z `signals.json`: session ID, parent, projekt, model, czasy, liczba tur/subagentów. Dokładne per-turn token receipts lepiej oprzeć później na oficjalnym opt-in OTel niż na raw chat history. Grok OTel udostępnia `grok_code.token.usage` i zdarzenia turn/API/tool, lecz schema jest oznaczona jako alpha. Źródło: [Grok Build Monitoring Usage](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md). + +### 4.3. xAI API to osobna integracja + +Oficjalny xAI Responses API ma streaming, structured outputs, usage i `cost_in_usd_ticks`, więc `Analyze with Grok` w trybie BYO API key jest relatywnie proste. Źródła: [xAI inference API](https://docs.x.ai/developers/rest-api-reference/inference), [xAI cost tracking](https://docs.x.ai/developers/cost-tracking). + +Management API może zwracać teamowe usage, saldo prepaid oraz spending limits, ale wymaga osobnego Management Key i nie reprezentuje planu SuperGrok. Źródło: [xAI Billing Management API](https://docs.x.ai/developers/rest-api-reference/management/billing). + +### Co jest łatwe, a co trudne + +| Element | Ocena | Powód | +|---|---:|---| +| `Analyze with Grok` przez BYO API key | łatwe–średnie | HTTP/JSON, schema outputs i dokładny koszt requestu. | +| xAI API team billing | średnie | Stabilne API, ale Management Key, Keychain, team selection i paginacja. | +| Grok local session metadata | łatwe–średnie | Jawny układ katalogów i summary; trzeba wersjonować parser. | +| Grok Build allowance przez ACP | średnie / beta | Dobry technicznie flow, ale metoda jest niewersjonowana. | +| Dokładne lokalne token receipts | średnie–trudne | OTel alpha albo parsing richer session stream z ryzykiem privacy. | +| Bezpośredni consumer REST | brak wspieranego kontraktu | Nie ma publicznego API; nie używać prywatnego proxy/cookies. | +| Ujednolicenie SuperGrok i xAI API | błędne domenowo | To inne auth, pool i billing ledger. | + +## 5. OpenCode + +### OpenCode nie jest jednym providerem + +OpenCode jest coding agentem i orchestratoriem wielu providerów. Provider i model są wymiarami jego sesji. Szczególne oferty `opencode`/Zen i OpenCode Go są dostawcami rozliczenia, ale nie należy z nich wnioskować o wszystkich sesjach OpenCode. Źródło: [OpenCode providers](https://opencode.ai/docs/providers/). + +Najlepsza oficjalna powierzchnia integracji to lokalny server: + +- `opencode serve --hostname 127.0.0.1 --port 0`; +- OpenAPI pod `/doc`; +- health, projects, sessions, children i providers; +- opcjonalne SSE events; +- Basic Auth przez zmienne środowiskowe. + +Źródło: [OpenCode server](https://opencode.ai/docs/server/). + +Aktualny schemat sesji ma skumulowane: + +- `cost`; +- tokeny input/output/reasoning/cache read/cache write; +- provider/model/variant; +- agent, parent session, projekt, directory i timestamps. + +Źródło: [OpenCode `Session.Info` v1.18.18](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/session.ts#L224-L245). + +To pozwala zbudować wartościową integrację bez czytania transcriptu i credentials. `opencode stats` nie ma maszynowego JSON, bezpośredni SQLite jest wewnętrznym storage, a `auth.json` jest poza zakresem. Oficjalne komendy i ich ograniczenia opisuje [OpenCode CLI](https://opencode.ai/docs/cli/). + +### Minimalny wariant + +**OpenCode Local Activity:** + +1. uruchomić własny child `opencode serve` tylko na loopback; +2. wygenerować losowe Basic Auth wyłącznie dla procesu potomnego; +3. sprawdzić health i wersję; +4. pobrać projekty oraz bounded listę sesji; +5. dekodować tylko metadata/session totals; +6. normalizować sesje i parent/child do lokalnych faktów; +7. przechowywać źródło, wersję i czas obserwacji; +8. na początku używać pollingu, nie SSE; +9. nie pobierać messages/parts, bo odpowiedź niesie Source Content. + +Koszt należy nazywać **OpenCode local estimated cost**, nie rachunkiem. Nie obejmuje użycia tego samego providera poza OpenCode ani rabatów, abonamentów czy billing corrections. + +### OpenCode Go + +Kod OpenCode v1.18.18 ma endpoint `GET /zen/go/v1/usage` z rolling/weekly/monthly percentage i `resetsAt`, ale endpoint nie jest wymieniony jako wspierany publiczny kontrakt i wymaga API key. Publiczne docs kierują użytkownika do console. Źródła: [OpenCode Go](https://opencode.ai/docs/go/), [implementacja endpointu v1.18.18](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/console/app/src/routes/zen/go/v1/usage.ts). + +Nie wdrażałbym go, dopóki OpenCode nie udokumentuje endpointu albo lokalny server nie zacznie proxy'ować usage bez ujawniania credentials. + +### Co jest łatwe, a co trudne + +| Element | Ocena | Powód | +|---|---:|---| +| Binary + health probe | łatwe | Oficjalny child server i JSON HTTP. | +| Sessions, token totals, cost, parent/child | łatwe–średnie | Są w `Session.Info`; potrzebna iteracja po projektach i tolerant decoding. | +| Provider/model discovery | łatwe | `GET /provider`; to katalog, nie quota. | +| Live SSE | średnie | Reconnect, bootstrap, dedupe i wersje eventów; zbędne w v1. | +| Per-turn/model/tool receipts | średnie–trudne | Wymaga messages/parts, które niosą treść. | +| Generic quota Anthropic/xAI/OpenAI | niewykonalne | OpenCode nie ma wspólnego billing ledger providerów. | +| OpenCode Go quota | technicznie średnie, produktowo wysokie ryzyko | Wewnętrzny endpoint i secret. | +| Wspólny runway Codex + OpenCode | błędne domenowo | Miesza allowance konta z lokalnym kosztem wielu providerów. | + +## 6. Rekomendowana architektura + +### 6.1. Nie jeden `Vendor`, tylko trzy niezależne capability boundaries + +W warstwie kompozycji utrzymywać trzy osobne zestawy implementacji, wszystkie identyfikowane przez mały `IntegrationID`: + +```text +AccountAllowanceSource -> okna used/remaining/reset i świeżość +LocalActivitySource -> lokalne sesje/task trees/tokeny/koszt z provenance +AssistedAnalysisRunner -> jawnie uruchamiane wywołanie modelu +``` + +Na początku mogą to być value types z closure'ami, nie fabryki ani duża hierarchia protokołów. Każde źródło implementuje tylko zdolność, którą naprawdę ma: + +- Codex: wszystkie trzy; +- Claude beta: tylko allowance; +- Grok Build beta: allowance + ograniczona local activity; +- xAI API: billing/runner, nie SuperGrok allowance; +- OpenCode: local activity, opcjonalnie runner; +- Claude Organization: billing/analytics, nie Pro/Max allowance. + +### 6.2. Neutralny kontrakt allowance + +Silnik prognozy potrzebuje mniej pól niż cały `UsageSnapshot`: + +```text +AllowanceReading + sourceID + observedAt + freshness: live | observed | stale + primaryWindowID? + windows[] + id + usedPercent + resetsAt + durationMinutes? + accountIdentity? +``` + +Codex-specific facts — banked resets, lifetime tokens, credits — pozostają osobnymi faktami Codex. Nie tworzyć pustych odpowiedników dla Claude, Grok lub OpenCode. + +Prognoza i guidance działają tylko, gdy źródło ma prawdziwe okno procentowe, reset i wystarczająco świeże próbki. OpenCode Local Activity nie przechodzi przez ten silnik. + +### 6.3. Historia i migracja + +Każda próbka historii musi mieć co najmniej `sourceID`. Klucz partycji powinien być semantycznie: + +```text +(sourceID, stableAccountIdentity || localInstallationPartition) +``` + +Migracja istniejących rekordów bez `sourceID` przypisuje je do Codex. Claude i Grok bez stabilnego account ID pozostają w partycji lokalnej i nie są automatycznie synchronizowane między urządzeniami. Zmiana loginu bez wspieranego identity tworzy comparison break zamiast łączyć historie. + +### 6.4. UI + +Najmniejszy sensowny UI to selektor integracji albo osobne karty, nie suma procentów. Każda karta pokazuje: + +- nazwę i typ źródła; +- primary/other windows tylko jeśli istnieją; +- `live`, `last observed` albo `stale`; +- Coverage i brakujące capability; +- provider-specific facts w osobnej sekcji; +- Local Activity oddzielnie od Account Allowance. + +Nigdy nie agregować `65% Claude + 20% Grok` w jeden procent. Procenty mają różne koszty, okna i jednostki. + +## 7. Kolejność wdrożenia i orientacyjny koszt + +Szacunki zakładają jednego inżyniera znającego Swift/macOS, brak backendu, zachowanie bieżącej jakości testów i brak zmian w istniejących kontraktach vendorów. To widełki wdrożeniowe, nie kalendarzowe zobowiązanie. + +| Etap | Zakres | Szacunek | Ryzyko | +|---|---|---:|---:| +| 0. Capability spikes | Fixtures rzeczywistych odpowiedzi Claude/Grok/OpenCode, wersje minimalne, auth modes | 2–4 dni | niskie | +| 1. Provider boundary | `IntegrationID`, allowance reading, historia per source, migracja Codex, UI source selection, regresje | 6–10 dni | średnie | +| 2. Claude Pro/Max beta | Relay statusLine, cache/TTL, konflikt istniejącego status line, 5 h/7 dni, stale UX | 4–7 dni | średnie | +| 3. OpenCode Local Activity | Child server, Basic Auth, projects/sessions, totals, parent/child, dedupe, Coverage | 4–7 dni | niskie–średnie | +| 4. Grok billing beta | ACP lifecycle, `x.ai/billing`, tolerant schema, weekly/monthly, fixtures kilku wersji/auth modes | 4–8 dni | średnie–wysokie | +| 5. Grok local metadata | Discovery sesji, summary/signals allowlist, trees, history | 3–6 dni | średnie | +| 6. xAI API billing | Management Key w Keychain, teams, usage/balance/spend, paginacja | 5–9 dni | średnie | +| 7. Rich OTel activity | Lokalny receiver i normalizacja dla jednego CLI | 8–15 dni na pierwszy source | wysokie | +| 8. Inny analysis runner | BYO key, model discovery, schema, privacy/preflight, cost, cancellation | 5–10 dni per ekosystem | średnie–wysokie | + +Realistyczny pierwszy release wieloźródłowy to etapy 0–3: **około 3–5 tygodni**, jeśli obejmuje pełną migrację, UI i testy. Dodanie Grok billing beta zwiększa zakres o około tydzień i stały koszt compatibility maintenance. + +## 8. Co byłoby naprawdę łatwe + +- wykrywanie zainstalowanych CLI i wersji; +- dekodowanie Claude 5 h/7 dni z `statusLine`; +- OpenCode health/projects/session totals przez HTTP; +- podstawowe metadata sesji Grok Build; +- xAI Responses API z structured output i per-request cost; +- provider/model jako provenance lokalnej sesji; +- source selector i osobne karty, jeśli najpierw dodamy `sourceID` do historii. + +## 9. Co byłoby naprawdę trudne + +- zachowanie poprawnej historii przy zmianie loginu, gdy CLI nie daje stabilnej identity; +- odświeżanie Claude Pro/Max na żądanie bez zużycia allowance; +- utrzymanie Grok `x.ai/billing` mimo braku wersjonowanego publicznego kontraktu; +- generic provider quota przez OpenCode — nie istnieje taki ledger; +- per-turn tokeny i tool timing bez wciągania promptów/responses do pamięci aplikacji; +- porównywalny koszt między subscription, prepaid, PAYG i ceną lokalnie estymowaną; +- wspólny runway wielu providerów bez fałszywej normalizacji; +- bundlowanie lub automatyczne użycie cudzych credentials zgodnie z warunkami, prywatnością i Keychain; +- pełny multi-provider `Analyze`, bo obecne preflight, sandbox i pomiar overhead są Codex-specific. + +## 10. Czego nie robić + +- Nie tworzyć jednego szerokiego `VendorProtocol` z kilkunastoma optional methods. +- Nie przepisywać `CodexClient`; owinąć stabilną, istniejącą ścieżkę adapterem. +- Nie przedstawiać braku capability jako zera. +- Nie czytać Claude/Grok/OpenCode credentials ani browser cookies. +- Nie wykonywać dummy prompts dla odświeżenia limitów. +- Nie parsować tekstowego `opencode stats` ani bezpośrednio SQLite. +- Nie pobierać OpenCode messages lub Grok chat history w pierwszej wersji. +- Nie nazywać local estimated cost rachunkiem. +- Nie mieszać SuperGrok z xAI API ani OpenCode generic z OpenCode Go. +- Nie robić OTel receivera przed potwierdzeniem, że podstawowe integracje są używane. + +## Rekomendowana decyzja + +**Go**, ale jako produkt wieloźródłowy, nie „czterech równych vendorów”. + +Najlepsza kolejność wartości do ryzyka: + +1. **Claude allowance beta** — największa nowa wartość przy publicznym schemacie danych. +2. **OpenCode Local Activity** — najczystsza integracja techniczna i naturalne rozszerzenie Usage Receipts. +3. **Grok Build billing beta** — funkcjonalnie atrakcyjne, ale wymaga jawnego compatibility budget. +4. **Grok local metadata** — przydatne bez naruszania treści. +5. **xAI/Claude organization billing i alternatywni analysis runners** — tylko po osobnej walidacji popytu. + +Jeżeli użytkownicy chcą jedynie pasków limitów, rekomendacja brzmi: użyć lub współtworzyć CodexBar. Jeżeli chcą zrozumieć, **dlaczego** allowance znika, które task trees je zużyły i jak wiarygodna jest prognoza, powyższy zakres rozszerza Codex Limits bez utraty jego najważniejszego kontraktu. + +## Raporty szczegółowe + +- [Claude Code integration feasibility](./claude-code-integration-feasibility-2026-08-18.md) +- [xAI / Grok provider feasibility](./xai-grok-provider-feasibility-2026-08-18.md) +- [OpenCode integration](./opencode-integration-2026-08-18.md) diff --git a/docs/research/opencode-integration-2026-08-18.md b/docs/research/opencode-integration-2026-08-18.md new file mode 100644 index 0000000..42da452 --- /dev/null +++ b/docs/research/opencode-integration-2026-08-18.md @@ -0,0 +1,291 @@ +# OpenCode w Codex Limits: źródło telemetrii, nie jeden vendor + +Data researchu i dostępu do źródeł: **2026-08-18**.\ +Zakres wersji: oficjalne docs OpenCode dostępne tego dnia oraz oficjalny release [`v1.18.18`](https://github.com/anomalyco/opencode/releases/tag/v1.18.18) z 2026-08-13.\ +Źródła zewnętrzne w tym dokumencie są wyłącznie pierwotne: dokumentacja i repozytorium OpenCode. + +> Aktualizacja runtime 2026-08-22: opis poniżej pozostaje przyszłym kontraktem domenowym, ale rekomendowany collector `opencode serve --pure` został odrzucony po pomiarze około 736 MiB RSS, około 66 MiB zapisów inicjalizacyjnych i stałego portu 4096 mimo żądania portu 0. OpenCode jest odroczony poza v1 do czasu pojawienia się wspieranego, znacznie lżejszego źródła. Zobacz [wyniki walidacji](multi-integration-v1-validation-spikes-2026-08-22.md). + +## Wniosek + +**OpenCode powinien wejść do produktu jako lokalne źródło telemetrii/agregator, a nie jako provider równoległy do Anthropic, xAI czy OpenAI.** + +OpenCode jest open-source'owym agentem programistycznym z TUI, aplikacją desktopową i rozszerzeniem IDE. Sam obsługuje ponad 75 providerów i modele lokalne. Jedna sesja OpenCode może używać kolejno różnych par `providerID/modelID`; oficjalny schemat zapisuje je przy odpowiedziach asystenta. To czyni OpenCode dobrym źródłem lokalnych faktów o pracy wykonanej przez OpenCode, ale nie autorytatywnym źródłem stanu kont każdego providera ([Intro](https://opencode.ai/docs/), [Providers](https://opencode.ai/docs/providers/), [schemat wiadomości `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/message.ts)). + +Najmniejszy sensowny produktowy zakres to: + +1. wykryć user-managed `opencode`; +2. uruchomić chroniony, lokalny `opencode serve` jako proces potomny; +3. czytać tylko `GET /global/health`, `GET /project` i `GET /session`; +4. pokazać OpenCode jako osobną sekcję **Local Activity**, z sumą tokenów, szacowanym kosztem, liczbą sesji, projektami i drzewami parent/child; +5. nie pokazywać „Usage remaining”, runway ani resetu, jeżeli źródło nie zwróciło prawdziwego okna limitu. + +Nie należy w pierwszej wersji czytać `auth.json`, parsować SQLite ani pobierać treści wiadomości. + +## 1. Obecny kontrakt repozytorium + +Repozytorium nie ma dziś abstrakcji `Vendor` ani provider-neutralnego konta. + +- [`CodexClient.swift`](../../Sources/CodexLimits/CodexClient.swift) wykrywa wyłącznie Homebrew-managed `codex` w `/opt/homebrew/bin/codex` lub `/usr/local/bin/codex`, uruchamia `codex app-server --stdio`, inicjalizuje JSONL RPC i odpytuje równolegle `account/rateLimits/read`, `account/usage/read` oraz `account/read`. +- Wynik ma postać `CodexFetchResult`, zawierającą jeden [`UsageSnapshot`](../../Sources/CodexLimits/UsageModels.swift), obserwację konta i plan. +- `UsageSnapshot` zakłada Codex-owy model produktu: `mainLimit`, `otherLimits`, historia tokenów, banked resets i Account facts. +- [`UsageMonitor.swift`](../../Sources/CodexLimits/UsageMonitor.swift) przyjmuje jedno `fetchUsage`, jeden `accountSnapshot` i jedną partycję konta. Główna analityka jest zbudowana wokół tygodniowego okna `10_080` minut. +- Lokalne fakty mają obecnie źródła tylko `codex-rollout-jsonl` i `codex-app-server-thread-list` w [`LocalActivityNormalizer.swift`](../../Sources/CodexLimits/LocalActivityNormalizer.swift). +- [`MEASUREMENT-CONTRACT.md`](../MEASUREMENT-CONTRACT.md) świadomie oddziela Account facts, Local facts i Derived estimates oraz zabrania zastępowania brakującego okna tygodniowego innym limitem bez nazwania go. + +Konsekwencja: podpięcie OpenCode pod `UsageSnapshot.mainLimit` byłoby błędem domenowym. OpenCode nie dostarcza uniwersalnego odpowiednika `account/rateLimits/read`. Najmniejszym punktem rozszerzenia jest lokalna warstwa aktywności, nie obecny klient konta Codex. + +## 2. Czym dokładnie jest OpenCode + +| Rola | Czy OpenCode ją pełni? | Znaczenie dla integracji | +|---|---:|---| +| Agent/klient codingowy | Tak | To podstawowa rola produktu. | +| Orchestrator wielu providerów | Tak | Provider i model są wymiarami telemetrii sesji OpenCode. | +| Provider modeli | Tylko w szczególnych ofertach `opencode`/Zen i `opencode-go` | Nie należy utożsamiać całego OpenCode z tymi ofertami. | +| Lokalny serwer/API | Tak | `opencode serve` publikuje HTTP + OpenAPI 3.1. | +| SDK | Tak | Oficjalny JS/TS SDK jest generowany z OpenAPI; nie jest potrzebny aplikacji Swift. | +| Daemon systemowy | Nie jako wymagany kontrakt | TUI uruchamia własny serwer, a osobne `opencode serve` tworzy nowy serwer. | +| Źródło lokalnej telemetrii | Tak | Sesje zapisują tokeny, koszt, model/provider, projekt i relacje parent/child. | +| Uniwersalne API limitów providerów | Nie | Brak jednego endpointu quota/rate limits dla wszystkich podłączonych providerów. | + +Oficjalna architektura mówi wprost, że TUI jest klientem serwera, `opencode serve` uruchamia headless HTTP server, `/doc` publikuje OpenAPI 3.1, a osobny `serve` nie dołącza się automatycznie do serwera TUI ([Server](https://opencode.ai/docs/server/)). + +## 3. Dostępne powierzchnie integracji + +### 3.1 HTTP server — najlepszy kontrakt produkcyjny + +`opencode serve --hostname 127.0.0.1 --port 0` uruchamia serwer na porcie przydzielonym przez system i wypisuje jego URL. Release `v1.18.18` ładuje kontekst projektu per request, więc serwer nie musi startować w konkretnym repo ([implementacja `serve`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/cli/cmd/serve.ts)). + +Istotne endpointy read-only: + +| Endpoint | Co daje | Ograniczenie | +|---|---|---| +| `GET /global/health` | `healthy`, wersja CLI | Dobry capability/version probe. | +| `GET /project` | lista znanych projektów/worktrees | Potrzebna do iteracji po projektach. | +| `GET /session` | sesje projektu, opcjonalnie child sessions | Domyślny limit implementacji to 100; API ma `start` i `limit`, ale brak wygodnego kursora wstecz. | +| `GET /session/:id/children` | bezpośrednie dzieci sesji | Pozwala odtworzyć drzewa agentów. | +| `GET /session/:id/message` | wiadomości i parts | Zawiera potrzebne metadane, ale także prywatną treść; nie używać w minimum. | +| `GET /provider` | wszystkie/default/connected providers i modele | Katalog dostępności, nie stan kwoty konta. | +| `GET /event` | SSE bieżącej instancji | Live events dla jednego kontekstu. | +| `GET /global/event` | SSE z polem `directory` i globalnymi zdarzeniami | Dobre później do live updates i wielu projektów. | + +Publiczna dokumentacja endpointów: [Server APIs](https://opencode.ai/docs/server/). Aktualny kod listowania sesji ma pola `scope`, `path`, `roots`, `start`, `search`, `limit` i routing workspace/directory ([Session API `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts), [implementacja listy](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/session.ts)). + +Serwer wspiera HTTP Basic Auth przez `OPENCODE_SERVER_PASSWORD`; username domyślnie to `opencode` i może być zmieniony przez `OPENCODE_SERVER_USERNAME` ([Server authentication](https://opencode.ai/docs/server/#authentication)). Proces potomny Codex Limits powinien zawsze ustawić losowe hasło, nasłuchiwać tylko na `127.0.0.1` i nie dodawać CORS. + +### 3.2 SSE — dobry etap drugi + +`GET /event` wysyła najpierw `server.connected`, a potem zdarzenia busa. Oficjalny SDK eksponuje ten sam strumień jako `event.subscribe()` ([Server events](https://opencode.ai/docs/server/#events), [SDK events](https://opencode.ai/docs/sdk/#events)). + +Przydatne eventy obejmują m.in. `session.created`, `session.updated`, `session.status`, `session.error`, `message.updated` i `message.part.updated`. SSE umożliwia mały koszt odświeżania, ale wymaga reconnectu, deduplikacji, bootstrapu po utracie połączenia i wersjonowania schematu. Polling read-only wystarcza w pierwszym wydaniu. + +### 3.3 `opencode run --format json` — dobre do sterowania, złe do pasywnego monitoringu + +CLI emituje NDJSON z `type`, `timestamp`, `sessionID` oraz eventami `step_start`, `step_finish`, `tool_use`, `text`, `reasoning` i `error`. `step_finish` zawiera tokeny i koszt ([CLI run](https://opencode.ai/docs/cli/#run), [emiter JSON `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/cli/cmd/run.ts)). + +Ten strumień obejmuje jednak sesję uruchomioną lub wznowioną przez dane wywołanie `run`. Użycie go jako telemetry source zmuszałoby Codex Limits do przejęcia uruchamiania zadań, co przeczy obecnej read-only granicy produktu. + +### 3.4 SDK — niepotrzebna zależność dla aplikacji Swift + +`@opencode-ai/sdk` może utworzyć client albo uruchomić client+server. Jest generowany z OpenAPI ([SDK](https://opencode.ai/docs/sdk/)). Aplikacja jest natywna w Swift, a potrzebne operacje to kilka `GET`-ów i SSE obsługiwane przez `URLSession`; dodanie Node/Bun albo warstwy JS tylko dla SDK nie daje przewagi. + +### 3.5 CLI helpers i SQLite — przydatne do spike'a, niewłaściwe jako stabilny kontrakt + +- `opencode session list --format json` zwraca tylko `id`, `title`, `updated`, `created`, `projectId` i `directory`; celowo pomija tokeny, koszt, model i parent ([implementacja `session list`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/cli/cmd/session.ts)). +- `opencode stats` pokazuje tokeny i koszty globalnie lub per projekt/model, lecz nie ma JSON output. Jego kod czyta wszystkie sesje z bazy i agreguje dane, po czym renderuje tekst ([CLI docs](https://opencode.ai/docs/cli/#stats), [implementacja `stats`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/cli/cmd/stats.ts)). Parsowanie tekstu byłoby kruche. +- `opencode export` zwraca JSON jednej sesji, ale zawiera transcript/file data; `--sanitize` redaguje treść, lecz nadal jest to ciężki eksport, nie inkrementalna telemetria ([CLI export](https://opencode.ai/docs/cli/#export)). +- `opencode db` potrafi wykonać SQL i zwrócić JSON/TSV ([CLI db](https://opencode.ai/docs/cli/#db)), lecz nazwy tabel/kolumn są wewnętrznym schematem i mogą migrować. Bezpośrednie odczyty `opencode.db` obchodzą API, wiążą aplikację ze storage i komplikują współbieżność. +- `opencode acp` używa nd-JSON po stdio, ale jest protokołem sterowania agentem, a nie historycznym API usage ([CLI ACP](https://opencode.ai/docs/cli/#acp)). + +## 4. Co można mierzyć wiarygodnie + +### 4.1 Fakty dostępne bez czytania transcriptu + +Schemat `Session.Info` w `v1.18.18` zawiera: + +- `id`, `projectID`, `workspaceID`, `directory`, opcjonalny `parentID`; +- opcjonalny wybrany `model { id, providerID, variant }` i `agent`; +- opcjonalne skumulowane `cost`; +- opcjonalne skumulowane tokeny: `input`, `output`, `reasoning`, `cache.read`, `cache.write`; +- wersję OpenCode oraz czasy `created`, `updated`, `archived`/`compacting`. + +Źródło: [oficjalny schemat sesji `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/session.ts#L224-L245). + +Z tych pól da się zbudować read-only: + +- listę i liczbę sesji/projektów; +- drzewa parent/child; +- sumę tokenów per sesja i globalnie; +- podział typów tokenów; +- lokalny, skumulowany koszt raportowany przez OpenCode; +- ostatnią aktywność i przybliżony czas życia sesji; +- aktualnie zapisany provider/model/variant/agent sesji. + +Wartości powinny mieć provenance `OpenCode local session`, wersję CLI i `observedAt`. Nie są Account facts providera. + +### 4.2 Fakty wymagające endpointu wiadomości + +Metadane odpowiedzi asystenta zawierają dokładne `providerID`, `modelID`, czasy, `cost` i rozbicie tokenów. Parts mogą zawierać czasy narzędzi oraz `step-finish` z usage ([schemat wiadomości](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/message.ts)). Dzięki temu można policzyć poprawny podział sesji multi-model, turn timing, tool time i usage w czasie. + +Problem: `GET /session/:id/message` zwraca jednocześnie prompts, responses i parts. Nawet jeśli dekoder Swift ignoruje te pola, wrażliwa treść przechodzi przez pamięć procesu. Obecny produkt mocno rozróżnia metadane od Source Content, więc ten endpoint powinien wymagać osobnej decyzji privacy i capability spike'a. Minimum powinno zostać przy `Session.Info`. + +### 4.3 Czego nie wolno wywnioskować + +- `Session.model` nie dowodzi, że cała sesja używała jednego modelu. Dokładne przypisanie jest per odpowiedź. +- Suma OpenCode nie obejmuje wywołań tego samego providera wykonanych przez Claude Code, Codex, SDK, stronę web ani inny komputer. +- Brak sesji OpenCode nie oznacza braku użycia konta providera. +- `updated - created` nie jest Active Time. +- Koszt OpenCode nie jest fakturą ani wydatkiem subscription planu. +- Limit kontekstu modelu z katalogu nie jest limitem konta ani allowance. +- OpenCode session tokens nie są porównywalne z Codex Account Token Activity bez osobnej walidacji definicji. + +## 5. Jak OpenCode liczy tokeny i koszt + +OpenCode normalizuje usage zwrócone przez provider/AI SDK do: + +- input bez cache read/write; +- output bez reasoning; +- reasoning; +- cache read; +- cache write. + +Następnie wylicza koszt z cen modelu (w tym progów kontekstowych), a reasoning tymczasowo wycenia jak output. Dla GitHub Copilot potrafi użyć `totalNanoAiu` zamiast zwykłej formuły. Brakujące lub niepoprawne liczby normalizuje do zera ([`getUsage` w `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/session/session.ts#L338-L411)). + +To oznacza: + +- tokeny są lokalnym zapisem usage zwróconego przez konkretną ścieżkę providera; +- `cost` jest kalkulacją OpenCode opartą o katalog/model metadata, z wyjątkami provider-specific; +- koszt może nie odpowiadać rzeczywistemu rachunkowi, rabatom, kredytom, abonamentowi, podatkom ani requestom poza OpenCode; +- gdy provider nie zwróci poprawnego usage albo cena jest niepełna, `0` nie musi znaczyć „darmowe”. + +W UI właściwa etykieta to **Estimated API cost reported by OpenCode** albo krócej **OpenCode local cost**, z tooltipem wyjaśniającym, że nie jest to bill/allowance. + +## 6. Provider i model discovery oraz auth + +OpenCode korzysta z AI SDK i Models.dev; `opencode models [provider]` pokazuje modele skonfigurowanych providerów, a `--verbose` dodaje m.in. koszt. `--refresh` odświeża cache Models.dev ([Providers](https://opencode.ai/docs/providers/), [CLI models](https://opencode.ai/docs/cli/#models)). + +HTTP `GET /provider` zwraca: + +- pełny katalog `all`; +- default models; +- listę `connected` provider IDs. + +To pozwala pokazać „OpenCode ma skonfigurowany xAI/Anthropic”, ale nie pozwala stwierdzić, że konto jest sprawne, ma dodatnie saldo albo określoną kwotę. + +Poświadczenia wprowadzone przez `/connect` są przechowywane w `~/.local/share/opencode/auth.json`; OpenCode może też wykrywać zmienne środowiskowe ([Providers credentials](https://opencode.ai/docs/providers/#credentials)). Codex Limits **nie powinien czytać ani kopiować tego pliku**. Proces OpenCode może używać swoich credentials wewnętrznie, ale nasz adapter powinien wywoływać wyłącznie lokalne endpointy telemetryczne. + +## 7. Limity: generic providers vs OpenCode Go/Zen + +### 7.1 Generic OpenCode + +W udokumentowanym lokalnym Server API nie ma odpowiednika: + +- provider account balance; +- billing usage; +- remaining subscription quota; +- rate-limit windows i reset times dla wszystkich providerów. + +OpenCode nie może więc być wspólnym źródłem „remaining” dla Anthropic, xAI, OpenAI, Bedrock itd. Każdy provider ma inny auth, rozliczenia i zasady. Takie integracje muszą być provider-specific albo pokazywać wyłącznie lokalną aktywność. + +### 7.2 OpenCode Go + +OpenCode Go jest konkretnym providerem/subskrypcją, nie całym OpenCode. Oficjalne docs `v1.18.18` podają trzy limity wartościowe: + +- 5 godzin: **$12 usage**; +- tydzień: **$30 usage**; +- miesiąc: **$60 usage**. + +Limity i lista modeli mogą się zmieniać, a aktualne usage oficjalnie śledzi się w console ([OpenCode Go](https://opencode.ai/docs/go/)). + +Repozytorium zawiera endpoint `GET https://opencode.ai/zen/go/v1/usage`, autoryzowany Bearer API key, zwracający dla `rolling`, `weekly`, `monthly`: `status`, `percent`, `resetsAt` ([implementacja `v1.18.18`](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/console/app/src/routes/zen/go/v1/usage.ts)). Ten endpoint **nie jest wymieniony w publicznej sekcji Go Endpoints**; docs kierują użytkownika do console. Jest technicznie obiecujący, ale dopóki OpenCode go nie udokumentuje jako wspierany kontrakt, integracja produkcyjna byłaby zależna od API wewnętrznego. + +Dodatkowo jego użycie wymagałoby dostępu do API key. Czytanie go z `auth.json` łamałoby obecną zasadę „nie kopiujemy credentials”. Rozsądne ścieżki na przyszłość: + +1. poprosić OpenCode o udokumentowanie endpointu i stabilnego schema/versioning; +2. poprosić o read-only proxy w lokalnym server API, które zwraca usage bez ujawniania klucza; +3. ewentualnie pozwolić użytkownikowi osobno wkleić Go API key do Keychain — dopiero gdy funkcja jest świadomie zamówiona. + +### 7.3 OpenCode Zen + +Zen jest pay-as-you-go: docs opisują saldo, auto-reload i miesięczne spending limits ustawiane w console, ale nie dokumentują lokalnego endpointu balance/remaining dla klienta OpenCode ([OpenCode Zen](https://opencode.ai/docs/zen/)). Lokalny koszt sesji nadal nie zastępuje salda Zen. + +## 8. Ocena trudności + +| Element | Ocena | Dlaczego | +|---|---|---| +| Wykrycie binary i `GET /global/health` | Łatwe | Ten sam wzorzec procesu potomnego co Codex; JSON HTTP. | +| Start prywatnego `serve` na loopback | Łatwe | `--port 0`, losowe Basic Auth, jedna linia z URL. | +| Pobranie projektów i sesji | Łatwe/średnie | API jest proste, ale trzeba iterować po projektach i uwzględnić limit 100. | +| Sumy session tokens/cost | Łatwe od `v1.18.18` | Pola są już w `Session.Info`, lecz opcjonalne i wymagają tolerant decoding. | +| Drzewa parent/child | Łatwe | `parentID` jest w sesji; obecne modele Usage Receipts znają relacje. | +| Provider/model catalog | Łatwe | `GET /provider`; nie mylić z quota. | +| Live updates przez global SSE | Średnie | Reconnect, bootstrap, dedupe i wersje eventów. | +| Historia tokenów per dzień/turn/model | Średnie/trudne | Wymaga messages/parts albo wewnętrznej bazy; endpoint niesie także treść. | +| Active Time/tool time | Trudne | Wymaga parts, definicji idle/wait i walidacji semantyki. | +| Account identity/partycjonowanie | Trudne | Lokalne sesje nie dają stabilnej tożsamości kont każdego providera. | +| Rzeczywisty rachunek providera | Trudne/niemożliwe generic | Local estimated cost nie jest billing ledger. | +| Remaining/reset dla Anthropic/xAI/OpenAI przez OpenCode | Niemożliwe generic | Brak uniwersalnego local server contract. | +| OpenCode Go remaining/reset | Średnie technicznie, wysokie ryzyko kontraktu | Endpoint istnieje w kodzie, ale nie jest publicznie udokumentowany i wymaga API key. | +| Wspólny runway dla Codex + OpenCode | Niewłaściwe | Miesza account allowance Codex z local estimated cost/token activity wielu providerów. | + +## 9. Minimalny wariant implementacyjny + +### Produkt + +Nowa pozycja **OpenCode · Local Activity**, bez allowance: + +- Sessions observed; +- Local tokens: input/output/reasoning/cache read/cache write; +- OpenCode local cost; +- Projects i session trees; +- Provider/model tylko jako zaobserwowany wymiar, z `Unknown/mixed` gdy nie da się tego dowieść z samego `Session.Info`; +- source version, observed time, Coverage reason. + +Nie dodawać jeszcze OpenCode do menu-barowego procentu, runway, reset reminders, banked resets, Account Token Activity ani `Usage per token` zestawionego z Codex allowance. + +### Transport + +1. Wykryj user-managed `opencode` w tych samych dwóch prefixach Homebrew co Codex. +2. Uruchom `opencode serve --hostname 127.0.0.1 --port 0 --pure`. +3. Ustaw losowe `OPENCODE_SERVER_USERNAME` i `OPENCODE_SERVER_PASSWORD` tylko w środowisku procesu potomnego. +4. Odczytaj linię `opencode server listening on http://...` i sprawdź `GET /global/health`. +5. Pobierz `GET /project`, a dla każdego worktree `GET /session?scope=project&directory=...&limit=`. +6. Dekoduj tylko pola sesji; ignoruj title/share/metadata, jeśli nie są potrzebne UI. +7. Przechowuj klucz źródłowy `opencode:` oraz poprzedni cumulative counter, żeby nie dublować kolejnych odczytów. +8. Zakończ child process przy zamknięciu/zmianie executable; przy niezgodnym schema pokaż `OpenCode activity unavailable`, nie zerowy usage. + +`--pure` ogranicza wpływ zewnętrznych pluginów na proces telemetryczny. Ceną jest to, że katalog custom-providerów/pluginów może nie odpowiadać interaktywnej instancji użytkownika; minimum nie potrzebuje katalogu do zsumowania już zapisanych sesji. + +### Model danych + +Najmniejsza zmiana nie wymaga budowy ogólnego frameworka vendorów. Wystarczy osobny read-only `OpenCodeActivitySource`, który produkuje provider-neutralne local facts oraz nowy `LocalActivitySourceKind`, np. `opencode-http-session`. + +Jednocześnie OpenCode nie powinien zwracać `UsageSnapshot`. Account allowance i Local Activity muszą pozostać rozdzielone. Jeżeli bezpośrednie adaptery Claude Code i xAI później potwierdzą wspólny, powtarzalny kontrakt, dopiero wtedy warto wydzielić protokoły `AccountUsageSource` i `LocalActivitySource`. + +### Jedna mała weryfikacja przed kodem produkcyjnym + +Spike powinien na fixture lub świeżej, sztucznej sesji sprawdzić: + +1. czy `GET /session` w minimalnie wspieranej wersji zawsze zwraca cumulative `tokens` i `cost`; +2. czy suma parent + child nie jest już zawarta w parent (ryzyko double count); +3. jak zachowują się counters po compaction, fork, archive i usunięciu; +4. czy `--pure` odczytuje tę samą historię bez ładowania zewnętrznych pluginów; +5. czy start serwera nie modyfikuje sesji poza konieczną migracją storage. + +Do czasu wyniku Coverage powinno mówić **Local OpenCode sessions observed; provider account usage may differ**. + +## 10. Co odłożyć + +- **Nie budować generic `Vendor` na podstawie samego OpenCode.** OpenCode i xAI są bytami z innych warstw. +- **Nie parsować `opencode stats`.** Tekst nie jest kontraktem maszynowym. +- **Nie czytać SQLite w produkcji.** HTTP ma już potrzebne agregaty. +- **Nie czytać `auth.json`.** Limity nie uzasadniają obchodzenia granicy credentials. +- **Nie pobierać messages/parts w v1.** Dodać dopiero, gdy użytkownik rzeczywiście potrzebuje per-turn/per-model historii i zaakceptuje przepływ Source Content. +- **Nie implementować SSE w v1.** Dziesięciominutowy polling i manual refresh pasują do obecnego `UsageMonitor`; SSE dodać, gdy opóźnienie faktycznie przeszkadza. +- **Nie nazywać `cost` wydatkiem/billingiem.** Jest to lokalna kalkulacja OpenCode. + +## Decyzja rekomendowana + +**Tak dla OpenCode jako `Local Activity source`; nie dla OpenCode jako generic account vendor.** + +To daje szybko wartościową obsługę sesji OpenCode używających Anthropic, xAI, OpenAI, OpenCode Go i innych modeli, bez udawania, że znamy pozostałe kwoty tych kont. Bezpośrednie integracje Claude Code i xAI powinny nadal odpowiadać za własne, autorytatywne fakty konta — o ile ich oficjalne interfejsy rzeczywiście je udostępniają. diff --git a/docs/research/reset-graph-root-cause-2026-08-06.md b/docs/research/reset-graph-root-cause-2026-08-06.md new file mode 100644 index 0000000..bb5b17c --- /dev/null +++ b/docs/research/reset-graph-root-cause-2026-08-06.md @@ -0,0 +1,153 @@ +# Reset graph root-cause research — 2026-08-06 + +## Executive conclusion + +The apparent recurrence is not a failure of the 0.2.8 fix. The screenshot was produced by the still-running installed app at `/Applications/Codex Limits.app`, which is **version 0.2.6, build 7**. Its process started on 2026-08-05 at 13:06:47, before 0.2.8 was released, and that build has no Sparkle updater. The visible `2.8` at the screenshot edge is therefore not evidence that the running binary is 0.2.8. + +The raw local history reproduces issue #67 exactly: + +- reset `2026-08-08 10:13:03Z`: 607 samples, 28–100% remaining; +- reset `2026-08-08 10:13:04Z`: two samples—70% at `2026-08-02 15:47:41Z` and 29% at `2026-08-06 14:37:27Z`; and +- reset `2026-08-08 10:13:05Z`: one sample. + +Version 0.2.6 groups those one-second reset variants as separate allowance windows. Step-end interpolation turns the sparse `70% -> 29%` window into the long horizontal plateau and final vertical drop visible in the screenshot. + +The released 0.2.8 regression test passes against this failure mode: the variants become one allowance window. The same test fails on tag 0.2.6 with two windows instead of one. The immediate fix is therefore to quit the old process and install/launch 0.2.8, not to change history or add another graph heuristic. + +The "draft usage was saved before the first real read" hypothesis is independently unsupported. Codex Limits awaits a complete `account/rateLimits/read` response before recording a sample, and current OpenAI Codex performs a real backend read rather than fabricating a percentage/reset. + +## Local evidence + +### Installed and running version + +The installed bundle metadata is `/Applications/Codex Limits.app/Contents/Info.plist`: + +```text +CFBundleShortVersionString = 0.2.6 +CFBundleVersion = 7 +``` + +`ps` identified PID `89649`, started `Wed Aug 5 13:06:47 2026`, executing `/Applications/Codex Limits.app/Contents/MacOS/CodexLimits`; `lsof` confirmed the same bundle path. + +This matches the project's documented upgrade boundary: [README line 146](https://github.com/thrr87/codex-limits/blob/3de3707/README.md#L146) says versions 0.2.6 and earlier require one final manual update. + +### Raw history + +The inspected files are under: + +```text +~/Library/Application Support/com.github.thrr87.CodexLimits/History/ + partitions/account-54d70c46ba4989e74b0c2eede6940a6c0f7b3639a51d64fe0016e6989da15911/ + installations/*/2026-08-*.json +``` + +Aggregation by `resetsAt` found: + +| Encoded `Date` value | UTC reset | Samples | Remaining range | +|---:|---|---:|---:| +| `807876783` | 2026-08-08 10:13:03 | 607 | 28–100% | +| `807876784` | 2026-08-08 10:13:04 | 2 | 29–70% | +| `807876785` | 2026-08-08 10:13:05 | 1 | one value | + +The numeric values are Swift `Date`'s JSON representation (seconds from Apple's 2001 reference date), not Unix timestamps. + +The sparse `:04` series is exactly: + +```text +2026-08-02T15:47:41Z 70% reset 2026-08-08T10:13:04Z +2026-08-06T14:37:27Z 29% reset 2026-08-08T10:13:04Z +``` + +With `.stepEnd`, those two points render as 70% until the second observation, then drop vertically to 29%. + +### Release regression check + +`UsageIntelligenceEngineTests/testResetTimeJitterDoesNotCreateASecondAllowanceWindow` passed 1/1 in the 0.2.8 release worktree. Running the equivalent test against tag 0.2.6 failed: + +```text +allowanceWindows: 2 (expected 1) +observed points: 1 (expected 3) +``` + +This is direct evidence that the shipped fix covers the observed one-second reset split, while the currently running binary does not. + +## What OpenAI Codex actually returns + +Research was performed against `openai/codex` commit [`57f42a81131ccf5933e7ec5dc659c381eeb5d72b`](https://github.com/openai/codex/commit/57f42a81131ccf5933e7ec5dc659c381eeb5d72b), current on 2026-08-06. + +### Snapshot schema + +`RateLimitWindow` contains only consumed percentage, optional duration, and optional `resets_at`. `resets_at` is documented as Unix seconds. `RateLimitSnapshot` has no capture timestamp, device/source identifier, account identifier, or stable allowance-window instance identifier. See the [core protocol types](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/protocol/src/protocol.rs#L2156-L2212). + +The app-server protocol rounds `used_percent` to an integer and passes `resets_at` through unchanged; it does not calculate a device-local reset. See the [v2 account protocol conversion](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/app-server-protocol/src/protocol/v2/account.rs#L507-L621). + +The backend model actually contains both `reset_after_seconds` and absolute `reset_at`, but the Codex client forwards the backend's absolute `reset_at`; it does not reconstruct it from the local clock. See the [backend model](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs#L13-L37) and [mapping into the protocol snapshot](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/backend-client/src/client.rs#L644-L656). + +### Full reads are backend reads, not drafts + +`account/rateLimits/read` calls `get_rate_limits_with_reset_credits`, which performs an authenticated GET against `/api/codex/usage` or `/wham/usage`. See the [backend request implementation](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/backend-client/src/client/rate_limit_resets.rs#L22-L35) and [endpoint selection](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/backend-client/src/client/rate_limit_resets.rs#L80-L85). + +The app-server returns an error when ChatGPT authentication is unavailable or when the backend returns no snapshots. It has no fallback percentage/reset construction. See [`get_account_rate_limits_response`](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/app-server/src/request_processors/account_processor.rs#L1047-L1117). + +The TUI's startup prefetch also invokes the same `account/rateLimits/read`; there is no fake 100% or other default window. See [background request startup](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/tui/src/app/background_requests.rs#L69-L105) and the [request itself](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/tui/src/app/background_requests.rs#L760-L770). + +There is one upstream "default" nuance, but it cannot explain a 70% line: the response-header parser can produce an empty default `codex` snapshot when no rate-limit headers are present. That snapshot has no primary/secondary usage window and therefore no percentage or reset to persist. See [`parse_all_rate_limits`](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/codex-api/src/rate_limits.rs#L22-L50). + +### Rolling updates are sparse + +Rate-limit data can also arrive from model-response headers or websocket `codex.rate_limits` events. The parser forwards `used_percent`, duration, and absolute reset values supplied by the server; it does not synthesize timestamps. See the [header/event parser](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/codex-api/src/rate_limits.rs#L52-L176). + +Official app-server documentation explicitly calls `account/rateLimits/updated` a **sparse rolling update** and tells clients to merge it into the latest full read or refetch. See the [account API overview](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/app-server/README.md#L2164-L2177) and [rate-limit field notes](https://github.com/openai/codex/blob/57f42a81131ccf5933e7ec5dc659c381eeb5d72b/codex-rs/app-server/README.md#L2300-L2341). + +Codex Limits does not persist such a notification directly. During a full read it notices concurrent rate-limit/account updates, repeats the read up to two times, and fails with `updatesDidNotSettle` if state remains unstable. See [Codex Limits 0.2.8 reconciliation](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/CodexClient.swift#L387-L419) and [batched full reads](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/CodexClient.swift#L500-L529). + +## Can reset values change between reads or devices? + +The protocol defines `resets_at` only as the next reset timestamp. It does not promise that the value is immutable for a nominal duration bucket, and current client code replaces primary/secondary windows whenever the server supplies a new snapshot. Because the absolute timestamp is backend-owned and passed through, two Macs do not independently calculate different reset dates; they can, however, observe different backend snapshots at different times. + +There is first-party confirmation of at least one legitimate reason for a reset timestamp to change: an OpenAI collaborator explained that after a global compensating reset, the next Codex use establishes a new five-hour and seven-day date ([openai/codex#13330 comment](https://github.com/openai/codex/issues/13330#issuecomment-3988712564)). + +There are also unresolved public field reports in the official repository: + +- [openai/codex#23190](https://github.com/openai/codex/issues/23190) records two 10,080-minute snapshots roughly two hours apart: `27% used / reset May 23`, then `99% used / reset May 18`. A later comment reports stale-looking history after resume/fork. This is very similar in shape to the current graph, but remains a user report, not an upstream root-cause determination. +- [openai/codex#23192](https://github.com/openai/codex/issues/23192) records incompatible percentages and reset timestamps between web analytics and the macOS app for the same account. +- [openai/codex#34874](https://github.com/openai/codex/issues/34874) reports a reset timestamp advancing on consecutive days without allowance replenishment. The issue explicitly labels its backend explanation as a hypothesis. + +These reports prove that materially different values have been observed in real clients. They do not prove whether the source is backend window selection, caching, a special reset, or client replay. + +## Why 0.2.6 draws the line and 0.2.8 does not + +Issue [codex-limits#67](https://github.com/thrr87/codex-limits/issues/67) documented real synchronized history with reset values `12:13:03` and `12:13:04`. PR [#68](https://github.com/thrr87/codex-limits/pull/68) fixed exact-reset grouping by aligning reset values within 15 minutes in memory while leaving stored observations unchanged. + +The 0.2.8 implementation: + +1. anchors reset timestamps only when they are within `tightBoundary = 15 minutes` ([alignment policy](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/UsageModels.swift#L145-L176)); +2. groups chart points by the aligned reset timestamp ([allowance-window grouping](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/UsageIntelligenceEngine.swift#L1842-L1880)); +3. starts a new observed segment whenever remaining percentage rises by more than 0.1 percentage point ([segment policy](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/UsageModels.swift#L178-L193)); and +4. renders every segment with `.stepEnd` interpolation ([chart rendering](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/MenuContentView.swift#L2403-L2428)). + +For the locally observed `:03`, `:04`, and `:05` reset variants, the alignment step places every sample in one allowance window. That removes the sparse second allowance series responsible for this screenshot. The focused release test confirms this behavior. + +The 15-minute rule does not claim to solve every possible backend inconsistency. Reset timestamps farther apart or genuinely conflicting backend windows would require separate evidence and policy. They are not needed to explain this incident. + +The history store deduplicates only exact `UsageSample` equality. Samples with a different observation time, percentage, or reset remain independent ([history normalization](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/UsageHistory.swift#L985-L1015)). This is intentional for preserving raw observations, but means a bad or stale reading remains visible until chart policy excludes or reconciles it. + +## Startup-cache hypothesis checked against Codex Limits + +On refresh, Codex Limits restores history for display, starts a live fetch, awaits the result, selects/verifies the account partition, exchanges synchronized history, and only then records the new main-window sample ([refresh flow](https://github.com/thrr87/codex-limits/blob/3de3707/Sources/CodexLimits/UsageMonitor.swift#L245-L324)). + +It can display a previously persisted account snapshot during startup, but that restored snapshot is not automatically appended to history before the live read. A new 70% history point therefore needs an actual successful fetch result or an already-existing/synchronized history record; local UI initialization alone is insufficient. + +## Root-cause assessment + +| Hypothesis | Assessment | Evidence | +|---|---|---| +| Old 0.2.6 process renders the known one-second reset split | **Confirmed root cause** | Installed/running version is 0.2.6; raw history has `:03/:04/:05`; sparse `:04` values exactly match the plateau; the 0.2.6 test fails. | +| 0.2.8 fix does not work | **Ruled out for this data** | Focused 0.2.8 regression passes and produces one allowance window. | +| Codex Limits writes a draft/default percentage before live fetch | **Ruled out by current source** | History record occurs only after awaited fetch; upstream full read errors rather than fabricates data. | +| Conflicting full backend snapshots | **Possible upstream context, not needed for this incident** | Protocol has no immutability guarantee and public reports show conflicts, but local one-second jitter plus old binary fully explains the graph. | +| Sparse rolling update saved as a full point by Codex Limits | **Unlikely in current code** | Codex Limits observes the notification only to trigger bounded full-read reconciliation. | + +## Action + +Quit PID `89649`, replace `/Applications/Codex Limits.app` with the 0.2.8 release, and launch it again. Verify the running bundle reports version 0.2.8 before judging the graph. No history edit is required: 0.2.8 aligns the raw reset variants in memory and intentionally leaves persisted observations unchanged. diff --git a/docs/research/xai-grok-provider-feasibility-2026-08-18.md b/docs/research/xai-grok-provider-feasibility-2026-08-18.md new file mode 100644 index 0000000..287305b --- /dev/null +++ b/docs/research/xai-grok-provider-feasibility-2026-08-18.md @@ -0,0 +1,392 @@ +# xAI / Grok jako vendor w Codex Limits + +Data researchu i dostępu do źródeł: **2026-08-18**\ +Zakres: aktualny kontrakt repozytorium, xAI Inference API, Management API, SuperGrok oraz Grok Build CLI\ +Źródła zewnętrzne: wyłącznie oficjalna dokumentacja xAI i oficjalne repozytorium `xai-org/grok-build` + +## Werdykt + +„Integracja xAI/Grok” oznacza dziś trzy różne produkty i trzy różne kontrakty: + +1. **xAI Inference API** — wywołania modeli przez `https://api.x.ai/v1`; integracja generowania i kosztu pojedynczego requestu jest łatwa. +2. **xAI API billing / Management API** — historia kosztów i tokenów, saldo prepaid oraz miesięczny spend control zespołu; integracja jest umiarkowanie łatwa, ale wymaga osobnego Management Key i nie opisuje limitu subskrypcji Grok. +3. **SuperGrok / Grok Build** — konsumencki pool obejmujący produkty Grok; publiczna dokumentacja REST nie opisuje odczytu jego stanu, ale oficjalny Grok Build udostępnia przez proces CLI własne rozszerzenie ACP `x.ai/billing`, zwracające procent wykorzystania oraz bieżący okres tygodniowy lub miesięczny. + +W konsekwencji: + +- **łatwe:** użycie Groka do „Assisted Insights”, lista modeli, metryki i dokładny koszt requestów wykonanych przez Codex Limits; +- **średnie:** monitoring płatnego xAI API przez Management API, pasywna lista lokalnych sesji Grok Build oraz eksperymentalny odczyt subscription usage przez CLI ACP; +- **trudne:** dokładna, odporna na zmiany normalizacja lokalnych tokenów Grok Build; +- **bez publicznego, stabilnego kontraktu:** odpowiednik głównego paska „weekly usage remaining” dla SuperGrok/Grok Build. Oficjalna implementacja istnieje, lecz jest wewnętrznym, niewersjonowanym rozszerzeniem CLI, a nie udokumentowanym REST API. + +Minimalna wiarygodna wersja vendora xAI powinna więc pokazywać **lokalną aktywność Grok Build**, opcjonalnie **koszty xAI API** oraz — po udanym capability probe — **subscription usage odczytane przez `grok agent stdio`**. Gdy metoda lub oczekiwane pola są niedostępne, tygodniowy limit musi pozostać jawnie niedostępny. Nie należy nazywać samej podmiany modelu w Assisted Insights „pełnym wsparciem xAI”. + +## 1. Obecny kontrakt i flow Codex Limits + +Repozytorium nie ma jeszcze ogólnego kontraktu vendora. Ma natomiast trzy wyraźne warstwy, które można rozdzielić na etapie kompozycji aplikacji. + +### 1.1. Konto i główny limit + +[`CodexClient.swift`](../../Sources/CodexLimits/CodexClient.swift) uruchamia lokalne `codex app-server --stdio`, inicjalizuje JSON-RPC i przy każdym odczycie pobiera równolegle: + +- `account/rateLimits/read`; +- `account/usage/read`; +- `account/read`. + +Klient uzgadnia odczyt ponownie, jeżeli w trakcie pojawi się `account/rateLimits/updated` albo `account/updated`. Następnie mapuje wynik do wspólnego w aplikacji `CodexFetchResult`/`UsageSnapshot`: + +- główny limit: okno dokładnie `10080` minut, procent pozostały i data resetu; +- pozostałe okna i limity modelowe; +- dzienne buckety tokenów i summary konta; +- credits, spend control i banked resets; +- tożsamość konta i plan. + +[`UsageMonitor.swift`](../../Sources/CodexLimits/UsageMonitor.swift) już przyjmuje `fetchUsage` jako closure, więc zamiana źródła konta nie wymaga na początku rozbudowanej hierarchii klas. Problemem jest semantyka: [`MEASUREMENT-CONTRACT.md`](../MEASUREMENT-CONTRACT.md) i silnik produktu traktują tygodniowy limit Codex jako główny, a historia i prognozy są oparte o spadający procent z konkretnym resetem. + +**Punkt rozszerzenia:** wstrzykiwany fetch jest dobry. `UsageSnapshot` nie jest jednak neutralnym modelem dla API pay-as-you-go bez tygodniowego allowance. + +### 1.2. Lokalne Taski i tokeny + +[`LocalActivityCollector.swift`](../../Sources/CodexLimits/LocalActivityCollector.swift) jest obecnie Codex-specific: + +- domyślny root to `~/.codex/sessions`; +- znajduje `rollout-*.jsonl` według katalogów kalendarzowych; +- używa `thread/list` i `thread/read` przez [`ThreadProjectionSource.swift`](../../Sources/CodexLimits/ThreadProjectionSource.swift) do read-only projekcji Tasków; +- inkrementalnie tailuje pliki, utrzymuje cursor/fingerprint i odtwarza stan po restarcie; +- [`RolloutTailSource.swift`](../../Sources/CodexLimits/RolloutTailSource.swift) rozumie zdarzenia Codex takie jak `session_meta`, `turn_context`, `token_count` i `compacted`; +- [`LocalActivityNormalizer.swift`](../../Sources/CodexLimits/LocalActivityNormalizer.swift) normalizuje je do tasków, parentów, agentów, tur, modeli, reasoning, tokenów, timingów i narzędzi. + +Źródła i wersje schematów są na razie zamkniętymi enumami `codex-rollout-jsonl` oraz `codex-app-server-thread-list`. Normalizowany model faktów jest w znacznej mierze wielovendorowy, ale discovery, wire parser, metadane źródła i copy są Codex-specific. + +**Punkt rozszerzenia:** zachować istniejące `LocalActivityFact` i agregatory, dodać osobny adapter Grok Build. Nie rozszerzać parsera rolloutów warunkami `if vendor == ...`. + +### 1.3. Assisted Insights + +[`CodexAssistedInsights.swift`](../../Sources/CodexLimits/CodexAssistedInsights.swift) korzysta z drugiego, izolowanego procesu Codex App Server: + +- `model/list` wybiera dokładnie wspierany profil; +- efemeryczny `thread/start` uruchamia analizę w read-only sandboxie, bez MCP, apps i web search; +- `turn/start` wysyła ograniczony payload i JSON Schema; +- przed i po analizie odczytywany jest tygodniowy limit, aby policzyć narzut na allowance. + +**Punkt rozszerzenia:** protokół `CodexAssistedInsightServicing` już izoluje usługę. Dla xAI można użyć natywnego `URLSession` i `POST /v1/responses` bez nowej zależności, ale utraci się Codexowy pomiar ruchu na allowance i gwarancje lokalnego sandboxa app-servera. Należy mierzyć zwrócony przez xAI koszt requestu, nie udawać procentu allowance. + +### 1.4. Najmniejsza potrzebna granica vendora + +Na początku wystarczy złożyć w jednym miejscu cztery zdolności: + +- identyfikator/nazwa vendora; +- opcjonalny fetch faktów konta; +- opcjonalny kolektor lokalnych faktów; +- opcjonalna usługa Assisted Insights. + +Każda zdolność musi móc być „unavailable” z nazwanym powodem. Wspólny protokół zakładający, że każdy vendor ma tygodniowe okno, daily token buckets, thread RPC i lokalny JSONL byłby błędny. + +## 2. xAI Inference API + +### 2.1. Endpoint i auth + +Oficjalny REST base to `https://api.x.ai`; przykłady OpenAI SDK używają `https://api.x.ai/v1`. Każdy request ma `Authorization: Bearer `. API key jest związany z teamem i ma ACL osobno dla endpointów i modeli. Zwykły klucz inference nie jest Management Key. Źródła: [Inference REST API overview](https://docs.x.ai/developers/rest-api-reference/inference), [Quickstart](https://docs.x.ai/developers/quickstart), [Accounts and Authorization](https://docs.x.ai/developers/rest-api-reference/management/auth). + +Przydatne read-only endpointy inference: + +- `GET /v1/api-key` — metadane aktywnego klucza, team/user ID, ACL i flagi blokady; +- `GET /v1/models` i `GET /v1/models/{id}` — lista modeli, kontekst i ceny; +- `GET /v1/language-models` — bogatsze capabilities, aliases, modalities, fingerprint i ceny; +- `POST /v1/responses` — rekomendowany interfejs generowania; +- `POST /v1/chat/completions` — interfejs zgodności, obecnie oznaczony jako deprecated przez xAI. + +Źródła: [Other inference endpoints](https://docs.x.ai/developers/rest-api-reference/inference/other), [Models API](https://docs.x.ai/developers/rest-api-reference/inference/models), [Responses vs Chat Completions](https://docs.x.ai/developers/model-capabilities/text/comparison). + +### 2.2. Zgodność z OpenAI API + +xAI deklaruje pełną zgodność REST z OpenAI API, publikuje przykłady dla OpenAI Python/JS SDK i akceptuje typowe formaty Chat Completions oraz Responses. To upraszcza transport, streaming i structured output. Nie oznacza to jednak zgodności z Codex App Server — xAI REST nie ma odpowiedników `account/rateLimits/read`, `thread/list` ani Codex rolloutów. + +Praktyczne granice zgodności: + +- Responses API jest kierunkiem rozwoju; Chat Completions jest legacy/deprecated; +- Chat Completions nie zwraca reasoning content i obsługuje tylko client-side function calling, podczas gdy Responses ma agentic tools i stateful conversations; +- wcześniejsza zgodność Anthropic Messages jest oficjalnie całkowicie deprecated; +- niektóre parametry OpenAI są model-dependent; przykładowo `logprobs` i `top_logprobs` są ignorowane przez Grok 4.20+; +- stateful Responses domyślnie przechowuje odpowiedzi po stronie xAI. + +Źródła: [Responses vs Chat Completions](https://docs.x.ai/developers/model-capabilities/text/comparison), [Legacy and deprecated endpoints](https://docs.x.ai/developers/rest-api-reference/inference/legacy), [Models](https://docs.x.ai/developers/models). + +### 2.3. Modele i discovery + +Na dzień researchu katalog rekomenduje **`grok-4.6` dla code i chat**. Ma 500k context, configurable reasoning i ceny dla krótkiego kontekstu $2/1M input, $0.50/1M cached input i $6/1M output; po przekroczeniu progu 200k cały request przechodzi na ceny long-context $4/$1/$12. Nadal widoczne są m.in. `grok-build-0.1`, `grok-4.5`, `grok-4.3` i modele 4.20. Źródła: [Models](https://docs.x.ai/developers/models), [Pricing](https://docs.x.ai/developers/pricing). + +Nie należy hardkodować modelu jako trwałego kontraktu. `GET /v1/models` zwraca tylko modele dostępne dla danego klucza wraz z bieżącym context length i cenami; aliasy mogą przesuwać się na nową wersję, a dated IDs służą do powtarzalności. Minimalna integracja powinna wybierać wspierany model dynamicznie i przechowywać efektywny model ID zwrócony w odpowiedzi. + +### 2.4. Streaming + +Modele tekstowe wspierają SSE po ustawieniu `stream: true`; obrazowe modele output nie wspierają tego trybu. Dla Responses pojawiają się typowane eventy, a dla Chat Completions delty. Reasoning może wymagać dłuższego timeoutu. Źródło: [Streaming](https://docs.x.ai/developers/model-capabilities/text/streaming). + +Dla Codex Limits streaming nie jest potrzebny w pierwszej wersji Assisted Insights, ponieważ obecny UI oczekuje końcowego, ustrukturyzowanego wyniku. Jeżeli zostanie dodany, parser SSE powinien być osobnym, małym elementem transportu i zawsze obsłużyć zakończenie bez usage. + +### 2.5. Token usage i dokładny koszt + +Odpowiedzi Chat Completions zwracają: + +- `prompt_tokens`, `completion_tokens`, `total_tokens`; +- `prompt_tokens_details.cached_tokens`; +- `completion_tokens_details.reasoning_tokens`. + +Responses zwraca odpowiednio `input_tokens`, `output_tokens`, `total_tokens`, cached i reasoning details. xAI dodaje `cost_in_usd_ticks`; `1 USD = 10_000_000_000 ticks`. Jest to faktyczny koszt danego requestu po cache discounts i z kosztami server-side tools. Pole jest per-request, nie cumulative. Źródła: [Usage and prompt-cache pricing](https://docs.x.ai/developers/advanced-api-usage/prompt-caching/usage-and-pricing), [Cost Tracking](https://docs.x.ai/developers/cost-tracking), [Chat REST reference](https://docs.x.ai/developers/rest-api-reference/inference/chat). + +W streamie: + +- xAI SDK podaje rosnący koszt w chunkach i finalnym response; +- OpenAI SDK/raw REST wymaga `stream_options: {"include_usage": true}`; +- końcowy chunk z pustym `choices` zawiera usage i finalny koszt. + +To jest bardzo dobry kontrakt dla kosztu Assisted Insights wykonywanego przez aplikację. Nie jest to historia całego konta i nie daje procentu pozostałego tygodniowego poolu. + +### 2.6. Structured outputs + +xAI wspiera `json_schema` i gwarantuje zgodność dla wspieranego podzbioru JSON Schema. Działa to także z Responses API. Obecne schematy wyników Codex Assisted Insights można więc wykorzystać bez budowania parsera tekstu. Źródło: [Structured Outputs](https://docs.x.ai/developers/model-capabilities/text/structured-outputs). + +### 2.7. Retencja i prywatność + +Domyślnie xAI przechowuje API inputs/outputs przez 30 dni do audytu i deklaruje, że nie trenuje na nich bez jawnej zgody. Responses jest domyślnie stateful; `store: false` wyłącza server-side conversation state, a Zero Data Retention jest ustawieniem całego teamu i wyłącza m.in. stateful Responses, Files, Collections, Batch oraz deferred completions. Każda odpowiedź ma `x-zero-data-retention: true|false`. Źródła: [API Security FAQ](https://docs.x.ai/developers/faq/security), [Generate Text](https://docs.x.ai/developers/model-capabilities/text/generate-text). + +Dla lokalnego produktu wysyłającego metadane zalecane jest `store: false`, brak server-side tools i jawny opis tego, co wychodzi z Maca. ZDR należy raportować na podstawie headera, a nie zgadywać z planu. + +## 3. Rate limits + +xAI API ma per-team, per-model limity w dwóch wymiarach: + +- RPS, wyliczany także z budżetu RPM; +- TPM, do którego wchodzą prompt, completion, reasoning oraz cached prompt tokens. + +Limity zależą od tieru opartego o skumulowany spend i mogą być indywidualnie podniesione. Przekroczenie daje `429`; oficjalna rekomendacja to exponential backoff. `grok-4.6` ma obecnie opublikowane dla tierów T0–T4 wartości 150/172/208/312/500 RPS i 50M/53M/60M/74M/100M TPM, ale produkt powinien używać model/team discovery, nie kopiować tej tabeli. Źródło: [Rate Limits](https://docs.x.ai/developers/rate-limits). + +Management API pozwala dodatkowo nadać konkretnemu API key ograniczenia `qps`, `qpm` i `tpm`, a team model listing zwraca konfiguracje modeli/rate limits. Źródła: [Management API guide](https://docs.x.ai/developers/management-api-guide), [Management auth reference](https://docs.x.ai/developers/rest-api-reference/management/auth). + +**Ważna luka:** w przeglądanych oficjalnych materiałach xAI nie dokumentuje kontraktu response headers typu „remaining requests/tokens/reset” ani publicznego endpointu bieżącego licznika rate-limit. Dokumentuje caps, status `429`, console i backoff. Nie należy implementować paska pozostałego RPS/TPM na podstawie nieudokumentowanych headerów. Rate limit jest też przepustowością, a nie allowance analogicznym do tygodniowego Codex. + +## 4. Management API: historia API, saldo i spend control + +Management API ma osobny base `https://management-api.x.ai` i wymaga osobnego **Management Key**. Klucz powstaje w xAI Console → Settings → Management Keys; użytkownik musi mieć odpowiednie uprawnienia. Zwykły `XAI_API_KEY` nie wystarczy. Źródła: [Management REST overview](https://docs.x.ai/developers/rest-api-reference/management), [Management API guide](https://docs.x.ai/developers/management-api-guide). + +Najważniejsze read-only endpointy billing: + +- `POST /v1/billing/teams/{team_id}/usage` — historia API dla przedziału, granularity, wartości, group-by i filtrów; odpowiedź ma `timeSeries` oraz `limitReached`; +- `GET /v1/billing/teams/{team_id}/prepaid/balance` — bieżące saldo prepaid i zmiany; +- `GET /v1/billing/teams/{team_id}/postpaid/spending-limits` — miękki/efektywny miesięczny limit; +- `GET /v1/billing/teams/{team_id}/postpaid/invoice/preview` — bieżące koszty i cykl billingowy; +- `GET /v1/billing/teams/{team_id}/invoices` — faktury. + +Usage query może agregować np. `usd` dziennie i grupować po opisie/modelu; Console Usage Explorer potrafi także pokazać cost, tokens, billing items oraz grupy/filtry po API key, modelu, IP, clusterze i typie tokenu. Źródła: [Billing Management API](https://docs.x.ai/developers/rest-api-reference/management/billing), [Usage Explorer](https://docs.x.ai/console/usage), [Manage Billing](https://docs.x.ai/console/billing). + +### Dopasowanie do Codex Limits + +To źródło może zasilić: + +- dzienne tokeny lub USD dla xAI API; +- saldo prepaid; +- miesięczny spend limit i wykorzystanie; +- breakdown po modelu/API key. + +Nie może uczciwie zasilić obecnego głównego `UsageWindow`, jeśli użytkownik nie ma prawdziwego procentowego limitu z czasem resetu. Saldo prepaid nie jest procentem tygodniowego allowance, a miesięczny soft limit nie obejmuje prepaid i może być zmieniany. Należy pokazać je jako osobne Account Facts/graphs, nie przepuszczać przez Codexowy forecast „will it last until weekly reset”. + +### Koszt wdrożeniowy i ryzyko + +Sam HTTP/JSON jest prosty. Trudniejsze są: + +- onboarding drugiego sekretu o szerszych uprawnieniach; +- bezpieczne przechowywanie w macOS Keychain; +- wybór teamu i rozdzielenie historii przy zmianie teamu; +- paginacja/cardinality (`limitReached`) i waluty/jednostki; +- fakt, że część użytkowników nie ma dostępu do Management Keys. + +Minimalny tryb powinien być całkowicie opcjonalny i read-only. Nie potrzebujemy endpointów tworzenia kluczy, top-up ani zmiany spending limitów. + +## 5. SuperGrok i Grok Build to nie to samo co billing xAI API + +Oficjalny consumer FAQ mówi, że płatne plany SuperGrok mają jeden procentowy tygodniowy pool, współdzielony przez produkty Grok; Settings → Usage pokazuje procent użyty, breakdown m.in. API/Build/Chat/Imagine/Voice, reset oraz Extra Usage Credits. Różne akcje zużywają różną ilość compute. Źródła: [Grok overview](https://docs.x.ai/grok/overview), [Grok Website / Apps FAQ](https://docs.x.ai/grok/faq). + +Jednocześnie oficjalna dokumentacja developerska opisuje xAI API jako teamowy produkt rozliczany przez prepaid credits albo monthly invoicing. Źródło: [Manage Billing](https://docs.x.ai/console/billing). + +Grok Build może działać w dwóch trybach auth: + +- browser/device OIDC przez Grok/`cli-chat-proxy.grok.com` z odświeżalną sesją; +- `XAI_API_KEY` i bezpośredni `api.x.ai` dla scripts/CI. + +Kolejność poświadczeń jest per-model: `model.api_key` → `model.env_key` → aktywny session token → `XAI_API_KEY`. Źródła: [Grok Build overview](https://docs.x.ai/build/overview), [Enterprise Deployments / Authentication](https://docs.x.ai/build/enterprise). + +**Wniosek:** produkt musi zapamiętać rodzaj auth/źródło billingu. Nie wolno zakładać, że: + +- saldo prepaid xAI API jest tygodniowym limitem SuperGrok; +- zwykły API key daje dostęp do konsumenckiego poolu; +- lokalna sesja Grok Build zawsze obciąża ten sam ledger; +- „API” pokazane w konsumenckim breakdown oznacza wszystkie requesty wszystkich teamowych API keys. + +Publiczna dokumentacja nadal **nie opisuje REST endpointu** do odczytu procentu weekly pool/resetu ani komendy `grok usage`. Jest jednak ważna oficjalna powierzchnia implementacyjna: Grok Build w commitcie `d71f6e0` definiuje custom ACP `x.ai/billing`. Handler wymaga auth zarządzanego przez Grok Build i sam wywołuje backend billingowy przez CLI proxy; pager Grok Build korzysta z tej metody zarówno przy starcie, jak i podczas odświeżania usage. + +Odpowiedź preferuje nowy shape: + +- `config.creditUsagePercent` — wykorzystanie w zakresie `0...100`; +- `config.currentPeriod.type` — m.in. `USAGE_PERIOD_TYPE_WEEKLY` lub `USAGE_PERIOD_TYPE_MONTHLY`; +- `config.currentPeriod.start` / `end` — granice okresu i reset; +- opcjonalnie `prepaidBalance`, `onDemandCap`, `onDemandUsed`, `isUnifiedBillingUser` oraz `subscriptionTier`. + +Oficjalny pager clampuje procent do `0...100`, bierze reset z `currentPeriod.end`, a po typie okresu wybiera etykietę „Weekly limit” lub „Monthly limit”. Zachowuje też fallback do oznaczonych jako deprecated pól `monthlyLimit`, `used` i `billingPeriodEnd`. To silny dowód, że kontrakt działa w bieżącym produkcie, ale jednocześnie sygnał ewolucji shape'u. + +Źródła: [`billing.rs`, commit `d71f6e0`](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-shell/src/extensions/billing.rs), [wywołanie ACP w pagerze](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/app/effects/mod.rs), [mapowanie billing → `CreditBalance`](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/app/effects/helpers.rs), [etykiety weekly/monthly](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/src/views/credit_bar.rs). + +### Ocena stabilności `x.ai/billing` + +To jest **oficjalny, lecz wewnętrzny i niewersjonowany kontrakt**: + +- znajduje się w oficjalnym repozytorium i korzysta z niego oficjalny pager, więc nie jest reverse engineeringiem; +- `x.ai/*` jest vendor extension ACP, a metoda i schema nie są opisane w publicznych docs jako kompatybilne API; +- handler ukrywa prywatny proxy/backend, który może zmieniać się razem z CLI; +- schema ma równolegle nowe i deprecated pola, więc integracja musi tolerować migracje; +- dostęp zależy od wariantu auth i subskrypcji; dla trybu tylko `XAI_API_KEY` nie wolno zakładać konsumenckiego subscription poolu; +- obecny typ odpowiedzi ignoruje obecne w backendowym przykładzie `productUsage`, więc nie należy obiecywać breakdownu per produkt. + +Wniosek wdrożeniowy: wolno wywoływać `x.ai/billing` **przez oficjalny proces `grok agent stdio`**, z wersją CLI zapisaną w provenance, bounded timeoutem, walidacją pól i fallbackiem `unavailable`. Nie wolno odtwarzać ukrytego requestu HTTP z kodu handlera, wywoływać `cli-chat-proxy` bezpośrednio ani wyciągać tokena z `~/.grok/auth.json`. + +## 6. Grok Build: lokalne Taski, tokeny i ACP + +### 6.1. Oficjalne powierzchnie + +Grok Build jest oficjalnym coding agentem z TUI, trybem headless oraz Agent Client Protocol. `grok agent stdio` uruchamia ACP po JSON-RPC; dokumentowany flow obejmuje auth, `session/new`, `session/prompt` i `session/update`. Źródła: [Grok Build overview](https://docs.x.ai/build/overview), [Headless & Scripting](https://docs.x.ai/build/cli/headless-scripting). + +CLI zapisuje sesje pod `~/.grok/sessions///` (albo pod `$GROK_HOME`): + +- `summary.json` — title, timestamps, model, message count i parent session; +- `updates.jsonl` — autorytatywny stream ACP do restore; +- `chat_history.jsonl` — raw messages do modelu; +- `plan.json`, `rewind_points.jsonl`, `signals.json` i katalog `subagents/`. + +Źródło: oficjalny [Grok Build sessions guide, commit `d71f6e0`](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/docs/user-guide/17-sessions.md). + +`signals.json` zawiera m.in. liczbę tur i tool calls, model IDs, bieżące wykorzystanie context window, compactions i latencje. Oficjalne źródło pokazuje jednak, że część per-turn token fields jest transportowa albo zapisywana inną ścieżką, więc nie wolno zakładać, że jeden cumulative counter z `signals.json` odpowiada Codex `total_token_usage`. Źródło: [`signals.rs`, commit `d71f6e0`](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-shell/src/session/signals.rs). + +Grok Build ma także opt-in external OpenTelemetry. Schema `v1` w statusie alpha udostępnia: + +- metric `grok_code.token.usage` z typami `input`, `output`, `reasoning`, `cache_read` i modelem; +- event `grok_code.api_request` z duration i tymi czterema licznikami; +- session/turn/tool/error events. + +Stream jest off by default, wymaga podwójnego opt-in i wskazania własnego OTLP collectora. Źródło: oficjalny [Monitoring Usage guide, commit `d71f6e0`](https://github.com/xai-org/grok-build/blob/d71f6e0c1f5acc5469e503e192fe14824e6f8c90/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md). + +### 6.2. Billing przez custom ACP + +`grok agent stdio` daje aplikacji transport JSON-RPC, na którym można wysłać pusty `ExtRequest` do `x.ai/billing`. Oficjalny pager robi to także na poziomie aplikacji, bez konieczności tworzenia lub wznawiania sesji zadaniowej. To lepsza granica niż czytanie auth file albo kopiowanie wewnętrznego HTTP: CLI pozostaje właścicielem logowania, odświeżania tokena i komunikacji z backendem. + +Adapter powinien parsować minimalny, preferowany podzbiór (`creditUsagePercent`, `currentPeriod.type/start/end`) i traktować pozostałe pola jako opcjonalne. Należy osobno obsłużyć: brak binarki, niezalogowanie, brak subskrypcji/config, `method_not_found`, timeout, parse error oraz nieznany typ okresu. Pole procentowe oznacza **used**, podczas gdy obecny Codex snapshot przechowuje głównie **remaining**; konwersja to `100 - used`, po clampie i z zachowaniem surowej wartości/provenance. + +Ta funkcja jest wdrażalna teraz, ale powinna otrzymać etykietę compatibility/experimental, testy fixtures przypięte do wersji CLI i telemetrykę błędów schematu bez zapisywania sekretów. + +### 6.3. Co jest łatwe + +Pasywny collector może bez uruchamiania/resumowania sesji: + +- znaleźć katalogi sesji; +- odczytać allowlistę z `summary.json` (session ID, parent, cwd-derived project label, timestamps, model); +- wykryć subagent tree; +- odczytać niesensytywne liczniki z `signals.json`; +- zachować provider/source version i nie kopiować raw messages. + +To wystarczy na listę lokalnych Tasków, modele, liczbę tur/tool calls, context usage i częściowe timing/agent facts. + +### 6.4. Co jest trudne + +Pełne lokalne Usage Receipts są trudniejsze niż sam discovery: + +- układ sesji jest dokumentowany, ale nie jest osobnym stabilnym analytics API; +- `updates.jsonl` jest streamem odtwarzania UI i może zawierać prompty, odpowiedzi, tool args, paths i outputs; +- `chat_history.jsonl` jest Source Content i nie powinien być skanowany w tle; +- `signals.json` nie daje prostego, kompletnego cumulative input/output/cached/reasoning kontraktu odpowiadającego obecnemu Codex parserowi; +- external OTEL ma najlepszy jawny token schema, ale jest alpha i wymaga konfiguracji użytkownika oraz odbiornika OTLP; +- nowy proces ACP nie jest udokumentowany jako globalny read-only feed cudzych aktywnych sesji; ACP służy do tworzenia/ładowania i prowadzenia sesji. + +Najbezpieczniejszy plan to najpierw pasywnie odczytać tylko `summary.json`/`signals.json`, oznaczyć Coverage zgodnie z faktycznymi polami, a dokładne token receipts odłożyć do osobnego spike'u opartego na syntetycznych fixtures aktualnej wersji CLI. Dla enterprise można później dodać jawnie włączany OTEL zamiast śledzić prywatne detale wszystkich JSONL. + +## 7. Macierz dopasowania do funkcji Codex Limits + +| Funkcja produktu | Oficjalne źródło xAI/Grok | Dopasowanie | Trudność | +|---|---|---|---| +| Assisted Insights | `POST /v1/responses`, JSON Schema, per-request cost | Bardzo dobre | Łatwe | +| Lista modeli/capabilities/cen | `GET /v1/models`, `/v1/language-models` | Bardzo dobre | Łatwe | +| Streaming odpowiedzi | SSE | Dobre, lecz niepotrzebne w MVP | Łatwe | +| Koszt analiz wykonanych przez aplikację | `usage.cost_in_usd_ticks` | Dokładny | Łatwe | +| Historia tokenów/USD całego xAI API teamu | Management billing usage | Dobre | Średnie | +| Prepaid balance | Management prepaid balance | Dokładne Account Fact | Średnie | +| Miesięczny spend control | Management spending limits/invoice preview | Dobre, ale nie weekly allowance | Średnie | +| Statyczne rate-limit caps | Console/Management team models | Dobre | Średnie | +| Bieżące remaining RPS/TPM/reset | Brak dokumentowanego licznika/header contract | Niedostępne | Trudne/blocked | +| SuperGrok/Grok Build weekly lub monthly % i reset | Custom ACP `x.ai/billing` w oficjalnym CLI | Dobre przy zgodnej wersji CLI i auth; brak stabilnego publicznego API | Średnie/ryzykowne | +| Lokalne sesje Grok Build | `~/.grok/sessions`, summary/signals | Dobre dla metadanych | Średnie | +| Dokładne lokalne token receipts | External OTEL alpha lub wersjonowany parser plików | Częściowe | Trudne | +| Globalny live Task feed | Brak udokumentowanego odpowiednika Codex app-server projections/events | Brak | Trudne | + +## 8. Proponowany minimalny wariant + +### Etap 1 — Grok Build Local (najmniejszy sensowny vendor) + +1. Wykryj oficjalny `grok` i `$GROK_HOME`/`~/.grok`. +2. Uruchom krótkotrwały `grok agent stdio` i wykonaj capability probe `x.ai/billing`; nie twórz ani nie wznawiaj Taska. +3. Gdy odpowiedź zawiera poprawne `creditUsagePercent` i `currentPeriod`, pokaż used/remaining, typ okresu i reset jako dane compatibility-gated. W przeciwnym razie pokaż named `unavailable`. +4. Dodaj osobny, pasywny collector `summary.json` + allowlista z `signals.json`. +5. Mapuj tylko fakty faktycznie obecne: session/parent/subagent, project label, timestamps, model, turns/tools, context usage. +6. Oznacz token totals jako unavailable, dopóki osobny spike nie potwierdzi kontraktu. +7. Partycjonuj historię przez `provider + lokalna tożsamość źródła`; nie czytaj ani nie kopiuj `auth.json`. + +To dostarcza realnej wartości lokalnej bez sekretów, prywatnych endpointów i nowego runtime. + +### Etap 2 — xAI API Billing (opt-in) + +1. Użytkownik jawnie dodaje Management Key do macOS Keychain i wybiera team. +2. Czytaj wyłącznie `usage`, `prepaid/balance`, `spending-limits` i ewentualnie invoice preview. +3. Pokazuj dzienne USD/tokens, saldo i miesięczny limit jako osobne fakty. +4. Nie twórz paska weekly forecast z tych danych. + +Nie implementować zarządzania kluczami, top-upów ani zmian limitów — Codex Limits ma read-only boundary. + +### Etap 3 — Grok jako Assisted Insights (opcjonalny i niezależny) + +1. Użyj `URLSession`, `POST /v1/responses`, dynamicznego model discovery i istniejącego JSON Schema. +2. `store: false`, bez tools i z bounded payloadem. +3. Zapisz input/output/cached/reasoning tokens, model i `cost_in_usd_ticks` dla tej analizy. +4. Pokaż użytkownikowi, że koszt obciąża xAI API, chyba że xAI zwróci oficjalną, jednoznaczną informację o innym ledgerze. + +### Etap 4 — stabilizacja subscription usage + +Wersję opartą o `x.ai/billing` utrzymywać jako compatibility-gated: probe przy starcie, jawne provenance z wersją CLI, fixtures dla nowego i legacy shape'u oraz bezpieczny fallback. Awansować ją do stabilnego kontraktu dopiero, gdy xAI udokumentuje endpoint/metodę i zasady kompatybilności albo opublikuje wersjonowaną maszynową komendę usage. Zawsze zachować osobny `provider` i `limitId`. + +## 9. Czego nie robić + +- Nie traktować OpenAI compatibility jako zgodności z Codex App Server. +- Nie skrobać `grok.com` ani nie wywoływać prywatnego `cli-chat-proxy` bezpośrednio; używać wyłącznie CLI-owned `x.ai/billing`. +- Nie wyciągać bearer tokena z `~/.grok/auth.json` i nie kopiować go do stanu aplikacji. +- Nie przeliczać tokenów na procent SuperGrok: consumer allowance jest compute-weighted per product. +- Nie traktować RPS/TPM jako allowance remaining. +- Nie scalać prepaid balance, monthly postpaid limit i SuperGrok weekly pool w jeden wykres. +- Nie skanować `chat_history.jsonl`/pełnych `updates.jsonl` w tle tylko po to, żeby zbudować listę Tasków. +- Nie hardkodować `grok-4.6` ani tabeli cen/rate limits bez model discovery. + +## 10. Szacunek relatywny + +| Zakres | Rozmiar | Główne ryzyko | +|---|---:|---| +| xAI Responses dla Assisted Insights | S | sekret, retencja, error mapping | +| Model discovery + per-request usage/cost | S | zmiany katalogu, final stream chunk | +| Grok Build summary/signals collector | M | schema/version/Coverage | +| Management API usage/balance/spend | M | Management Key, team identity, Keychain | +| Dokładne local token receipts z plików | L | niepełna/stabilna semantyka tokenów | +| OTEL collector w desktop app | L | setup, OTLP/protobuf, alpha schema | +| Grok subscription allowance przez `x.ai/billing` | M | oficjalna implementacja, ale wewnętrzny/niewersjonowany schema i zależność od CLI auth | +| Bezpośrednie odtworzenie prywatnego billing HTTP | Nie wdrażać | niestabilny backend i ryzykowny auth contract | + +## 11. Decyzja rekomendowana + +Włączyć xAI do szerszego projektu multi-vendor w dwóch jawnych wariantach: + +- **Grok Build (local):** lokalne sesje i Coverage; +- **xAI API (team billing):** usage, koszty, saldo i spend control. + +Nie udawać, że którykolwiek z nich jest bezwarunkowym odpowiednikiem obecnego Codex subscription monitor. Najpierw wdrożyć Grok Build local metadata oraz bounded probe `x.ai/billing`; oba używają oficjalnego CLI, nie wymagają kopiowania sekretów i wykorzystują istniejący model faktów. Management API dodać jako opt-in. Pasek subscription usage można pokazać eksperymentalnie po poprawnej odpowiedzi ACP, ale przy każdym braku metody/auth/schematu ma przechodzić w `unavailable`, a nie zgadywać. Publicznego, wersjonowanego REST API nadal brak. diff --git a/docs/usage-history-sync.md b/docs/usage-history-sync.md deleted file mode 100644 index 8d8ac98..0000000 --- a/docs/usage-history-sync.md +++ /dev/null @@ -1,45 +0,0 @@ -# Shared usage history - -## Scope - -Shared usage history is optional and contains only main-limit usage samples. A usage sample consists of its locally recorded observation time, remaining percentage, and the window reset returned by Codex. Preferences, launch settings, credentials, raw Codex responses, and other device state never sync. - -One sync folder represents one Codex account. The app does not identify or verify the account, so the settings interface tells the user to use the folder only on Macs signed in to the same Codex account. The folder must be private and not shared with other people. Files remain readable JSON without app-level encryption and contain no credentials or Codex content. - -## Behavior - -- Choosing a sync folder enables synchronization. The app accepts an empty folder that it can initialize or a folder with a supported Codex Limits format marker; it rejects other nonempty folders. -- On first connection, the app merges the existing local 90-day history with the history already in the folder without replacing either side. -- Sync runs on launch, wake, panel open, manual refresh, and the existing ten-minute background refresh. -- Each Mac imports available history independently during those refreshes. The app does not track whether another Mac has downloaded a change. -- Reading current usage from Codex and exchanging folder history are independent operations. Failure of either one does not prevent the other result from updating the app. -- Stopping sync disconnects the folder without deleting local or folder history. -- A missing folder or malformed history file never replaces valid local history or interrupts the main usage display. The app retries later and reports the problem only in settings. -- Valid files continue to merge when one history file is malformed. An unsupported folder version prevents all writes. - -## Storage and merge rules - -- Local history and its optional folder replica use the same versioned daily JSON format. -- Each installation has a random local identifier and writes only files belonging to that identifier. -- The tuple of observation time, remaining percentage, and window reset identifies a usage sample. Exact copies are deduplicated; readings made at different times remain distinct. -- A daily file larger than 1 MB is skipped and reported like any other unreadable history file. -- Samples older than 90 days are ignored. An installation removes only its own expired daily files and never deletes another installation's files. -- iCloud Drive access uses coordinated file operations. No SQLite database file is synchronized. - -Existing usage samples are migrated automatically from `UserDefaults` on the first launch that uses file-backed history. Migration is idempotent and keeps the legacy state available until the new daily files have been written and read successfully. After successful migration, usage history is removed from `UserDefaults`, which retains only small preferences and identifiers. - -## Settings - -The settings interface uses a `History sync` section with this description: - -> Keep usage history in a folder available on your other Macs. - -`Choose Folder…` selects and connects a folder. When connected, the section shows the folder name and `Stop Syncing`; it shows a short status only when there is a problem. There is no separate toggle, folder-opening action, sync-now action, last-sync timestamp, or claim that another Mac is up to date. The existing `Refresh` action also triggers sync. - -The account invariant is presented as: - -> Use this folder only on Macs signed in to the same Codex account. - -Folder privacy is presented as: - -> Choose a private folder that isn’t shared with other people.