Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
309 changes: 309 additions & 0 deletions CONTEXT.md

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")]
)
]
Expand Down
48 changes: 31 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

<table>
<tr>
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -102,20 +108,23 @@ 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.

## Requirements

- 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

Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.8</string>
<string>0.3.0</string>
<key>CFBundleVersion</key>
<string>9</string>
<string>10</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
Expand Down
8 changes: 8 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
90 changes: 90 additions & 0 deletions Scripts/check-grok-exit.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions Scripts/measure-app-idle.sh
Original file line number Diff line number Diff line change
@@ -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
Loading