swapdex 0.130.0

Switch between multiple Claude Code, Codex, Gemini, and Antigravity login accounts, locally and safely.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Codex's own rate limits, read from its session logs - no network at all.
//!
//! The Codex CLI records what the API told it about the account's windows into
//! the session transcript: `payload.rate_limits.primary` / `.secondary`, each
//! carrying `used_percent`, `window_minutes` and `resets_at`. So a Codex
//! account's usage can be shown the same way Claude's is, except it costs a local
//! file read instead of an HTTP request.
//!
//! The transcript does NOT say which account it belongs to - not in the
//! `rate_limits` block, not in the session header, nowhere in the file. So a
//! reading can only be attributed by WHERE it was read from, and each account's
//! home is read separately.
//!
//! Whatever else is true, the home is the right caption for a reading found in
//! it. An earlier version captioned each one with the payer from the switch
//! timeline instead, and that produced the symptom which started this: an
//! account with no transcripts at all showing a reading, beside the home holding
//! every one of them showing none. Nothing else surveyed attributes a reading to
//! anything but the credential that fetched it.
//!
//! This is no longer the only source, and it is the weaker one. `codex_usage`
//! asks the account itself and gets an answer that NAMES itself, so nothing has
//! to be inferred from where a file sits - and it answers for a home holding no
//! transcripts, which this module cannot do at all. Prefer it; what remains here
//! is the reading that costs nothing and still works offline or when the
//! endpoint is throttled.
//!
//! Upstream will not name the account in the transcript: openai/codex#16323
//! asked for a user id next to `rate_limits` and was declined, noting that on
//! Team plans quotas are per USER while the account id is shared.

use std::path::{Path, PathBuf};

/// One window as Codex reports it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Window {
    pub used_pct: f64,
    /// The window's length in minutes: 300 is the 5-hour window, 10080 a week.
    pub window_minutes: i64,
    pub resets_at: Option<i64>,
}

/// Both of an account's windows, shortest first (so `.0` is the session window
/// and `.1` the longer one, whatever lengths the API happens to use).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Limits {
    pub short: Option<Window>,
    pub long: Option<Window>,
    /// Unix seconds when the API stated these windows, taken from the record
    /// that carried them.
    ///
    /// It used to be the transcript's mtime, which moves every time Codex writes
    /// anything at all. A conversation that kept running without the API
    /// restating the windows made an hours-old snapshot look freshly taken - and
    /// the age IS the caveat for a reading taken from here.
    pub observed_at: Option<i64>,
}

/// A window's column, decided by its LENGTH rather than by the label the API
/// gave it. Codex sends the weekly window as `primary` when it is the only one,
/// so trusting the label puts a week's usage in the session gauge.
///
/// One function because two callers need the same answer - the display and the
/// proxy's own reading - and a rule copied into both drifts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Placed {
    pub five_h: Option<Window>,
    pub seven_d: Option<Window>,
}

pub fn place(l: &Limits) -> Placed {
    let mut p = Placed::default();
    for w in [l.short, l.long].into_iter().flatten() {
        if w.window_minutes <= 600 {
            p.five_h = Some(w);
        } else {
            p.seven_d = Some(w);
        }
    }
    p
}

fn window_from(v: &serde_json::Value) -> Option<Window> {
    let used_pct = v.get("used_percent")?.as_f64()?;
    Some(Window {
        used_pct,
        window_minutes: v
            .get("window_minutes")
            .and_then(serde_json::Value::as_i64)
            .unwrap_or(0),
        resets_at: v.get("resets_at").and_then(serde_json::Value::as_i64),
    })
}

/// Pull the newest `rate_limits` block out of one transcript.
///
/// The block carries `limit_id`, `plan_type` and the windows - and NO account
/// identifier. Neither does the session header. So a reading taken from here
/// cannot say whose it is; only where it was read from. This used to claim it
/// also pulled "the session's email", which the transcript has never contained,
/// and reading that comment is how someone would conclude these numbers arrive
/// already attributed.
fn from_transcript(path: &Path) -> Option<Limits> {
    scan_transcript(path)
}

/// Scan a transcript for its rate-limit lines WITHOUT holding it in memory.
///
/// This used to read the whole file into a String to find a handful of lines.
/// On a machine with 82 GB of Codex sessions the largest is 1.1 GB, so the
/// dashboard - which refreshes every 45 seconds - went from 8 MB to 2.1 GB on
/// every refresh, on a machine already 5 GB into swap. The same build on a
/// machine with small transcripts sat at 44 MB: same code, different data.
///
/// Only a few lines carry the field, and only the last one is kept, so nothing
/// needs to be resident but one line at a time.
fn scan_transcript(path: &Path) -> Option<Limits> {
    use std::io::BufRead;
    let file = std::fs::File::open(path).ok()?;
    let reader = std::io::BufReader::new(file);
    let mut limits: Option<Limits> = None;
    for line in reader.lines().map_while(Result::ok) {
        let line = line.as_str();
        // Cheap prefilter: parsing every line of a long transcript is the slow
        // part, and only a few carry this field.
        if !line.contains("\"rate_limits\"") {
            continue;
        }
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        {
            if let Some(rl) = find_key(&v, "rate_limits") {
                let short_first = |a: Option<Window>, b: Option<Window>| match (a, b) {
                    (Some(x), Some(y)) if y.window_minutes < x.window_minutes => (Some(y), Some(x)),
                    (a, b) => (a, b),
                };
                let (short, long) = short_first(
                    rl.get("primary").and_then(window_from),
                    rl.get("secondary").and_then(window_from),
                );
                limits = Some(Limits {
                    short,
                    long,
                    // The record's own stamp, when it carries one.
                    observed_at: v
                        .get("timestamp")
                        .and_then(serde_json::Value::as_str)
                        .and_then(crate::session_link::rfc3339_to_secs),
                });
            }
        }
    }
    limits
}

/// Depth-first search for the first value under `key` anywhere in the object -
/// the transcript nests these differently across event types.
fn find_key<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
    match v {
        serde_json::Value::Object(m) => {
            if let Some(found) = m.get(key) {
                if !found.is_null() {
                    return Some(found);
                }
            }
            m.values().find_map(|x| find_key(x, key))
        }
        serde_json::Value::Array(a) => a.iter().find_map(|x| find_key(x, key)),
        _ => None,
    }
}
/// The same reading, for ONE account's home.
///
/// Only the bare `~/.codex` was ever read, so an account that is a slot - which
/// is what `run`, `adopt` and `onboard` create - had its transcripts sitting in
/// a directory nothing looked at, and got no usage at all while another home's
/// numbers were displayed beside it.
pub fn for_slot(config_dir: &Path, now: u64, max_age_secs: u64) -> Option<Limits> {
    from_sessions_dir(&config_dir.join("sessions"), now, max_age_secs)
}

fn from_sessions_dir(dir: &Path, now: u64, max_age_secs: u64) -> Option<Limits> {
    let mut files: Vec<PathBuf> = Vec::new();
    collect_jsonl(dir, now, max_age_secs, &mut files);
    // Newest first, and stop at the first transcript that actually has limits.
    files.sort_by_key(|p| std::cmp::Reverse(mtime_secs(p)));
    let (path, raw) = files
        .iter()
        .find_map(|f| from_transcript(f).map(|l| (f, l)))?;
    let still_valid = |w: Option<Window>| w.filter(|w| w.resets_at.is_none_or(|r| r > now as i64));
    let l = Limits {
        short: still_valid(raw.short),
        long: still_valid(raw.long),
        // The file's mtime only stands in when the record carried no stamp.
        observed_at: raw.observed_at.or(Some(mtime_secs(path) as i64)),
    };
    (l.short.is_some() || l.long.is_some()).then_some(l)
}

fn mtime_secs(p: &Path) -> u64 {
    std::fs::metadata(p)
        .and_then(|m| m.modified())
        .ok()
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// `*.jsonl` under `dir` modified within `max_age` seconds. The mtime gate is
/// what keeps this fast across thousands of transcripts.
fn collect_jsonl(dir: &Path, now: u64, max_age: u64, out: &mut Vec<PathBuf>) {
    if let Ok(rd) = std::fs::read_dir(dir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() {
                collect_jsonl(&p, now, max_age, out);
            } else if p.extension().is_some_and(|x| x == "jsonl")
                && now.saturating_sub(mtime_secs(&p)) <= max_age
            {
                out.push(p);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::paths::Paths;

    /// A slot with no transcripts of its own reports nothing.
    ///
    /// This is the shape the payer caption broke: on a real machine an account
    /// with zero session files showed a reading, because a reading taken from
    /// ANOTHER home was captioned with whoever the timeline said was paying.
    /// A reading belongs to the home it was read from, so a home with none has
    /// none.
    #[test]
    fn a_home_with_no_transcripts_reports_nothing() {
        let d = tempfile::tempdir().unwrap();
        let with = d.path().join("has/sessions/2026/08/14");
        let without = d.path().join("none");
        std::fs::create_dir_all(&with).unwrap();
        std::fs::create_dir_all(without.join("sessions")).unwrap();
        write_transcript(&with, "a.jsonl", 16.0, Some(42.0));

        // Real "now": the age gate compares against the files just written, and
        // a far-future now would filter them out before the reset check ever
        // mattered - which is exactly what the first version of this test did.
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        assert!(
            for_slot(&d.path().join("has"), now, 10 * 86400).is_some(),
            "the home that holds the transcript reports its reading"
        );
        assert!(
            for_slot(&without, now, 10 * 86400).is_none(),
            "a home with no transcripts reports nothing - never another home's numbers"
        );
    }

    /// Two fixed moments for the fixtures: a window that has NOT reset and one
    /// that has. They were literal timestamps once, which passed until the day
    /// they went by in the real world and the test failed for the calendar
    /// rather than for the code.
    const LIVE_RESET: i64 = 4_102_444_800; // 2100-01-01
    const PAST_RESET: i64 = 1_000_000_000; // 2001-09-09

    /// The real transcript shape: `payload.rate_limits`, no account identity.
    fn write_transcript(dir: &Path, name: &str, primary: f64, secondary: Option<f64>) {
        write_transcript_resetting(dir, name, primary, secondary, LIVE_RESET)
    }

    /// The same, with the reset moment chosen by the caller.
    fn write_transcript_resetting(
        dir: &Path,
        name: &str,
        primary: f64,
        secondary: Option<f64>,
        reset: i64,
    ) {
        let sec = match secondary {
            Some(p) => {
                format!(r#"{{"used_percent":{p},"window_minutes":300,"resets_at":{reset}}}"#)
            }
            None => "null".into(),
        };
        let body = format!(
            "{{\"payload\":{{\"type\":\"other\"}}}}\n{{\"payload\":{{\"rate_limits\":{{\"primary\":{{\"used_percent\":{primary},\"window_minutes\":10080,\"resets_at\":{reset}}},\"secondary\":{sec}}}}}}}\n"
        );
        std::fs::write(dir.join(name), body).unwrap();
    }

    #[test]
    fn reads_the_windows_and_orders_them_shortest_first() {
        let d = tempfile::tempdir().unwrap();
        write_transcript(d.path(), "a.jsonl", 16.0, Some(42.0));
        let limits = from_transcript(&d.path().join("a.jsonl")).expect("parsed");
        // The 300-minute window is the session one, so it sorts first even though
        // the API called it "secondary".
        assert_eq!(limits.short.unwrap().used_pct, 42.0);
        assert_eq!(limits.short.unwrap().window_minutes, 300);
        assert_eq!(limits.long.unwrap().used_pct, 16.0);
        assert_eq!(limits.long.unwrap().window_minutes, 10080);
        assert_eq!(limits.long.unwrap().resets_at, Some(LIVE_RESET));
    }

    #[test]
    fn a_single_window_is_reported_alone() {
        let d = tempfile::tempdir().unwrap();
        write_transcript(d.path(), "b.jsonl", 7.5, None);
        let limits = from_transcript(&d.path().join("b.jsonl")).expect("parsed");
        assert_eq!(
            limits.short.unwrap().used_pct,
            7.5,
            "the only window is first"
        );
        assert!(
            limits.long.is_none(),
            "nothing invented for the missing one"
        );
    }

    #[test]
    fn a_transcript_without_limits_yields_nothing() {
        let d = tempfile::tempdir().unwrap();
        std::fs::write(d.path().join("c.jsonl"), b"{\"payload\":{\"x\":1}}\n").unwrap();
        assert!(from_transcript(&d.path().join("c.jsonl")).is_none());
        // Corrupt lines are skipped rather than failing the read.
        std::fs::write(d.path().join("d.jsonl"), b"{ broken\n").unwrap();
        assert!(from_transcript(&d.path().join("d.jsonl")).is_none());
    }

    // A window whose reset has passed describes a window that no longer exists,
    // so it is dropped rather than reported as still-used.
    #[test]
    fn a_window_past_its_reset_is_not_reported() {
        let d = tempfile::tempdir().unwrap();
        let sessions = d.path().join(".codex/sessions/2026/07/27");
        std::fs::create_dir_all(&sessions).unwrap();
        write_transcript_resetting(&sessions, "a.jsonl", 16.0, Some(42.0), PAST_RESET);
        let paths = Paths::rooted(d.path());
        // "Now" before the reset: both windows stand.
        let l = for_slot(paths.codex_dir(), PAST_RESET as u64 - 1, 10 * 86400)
            .expect("both windows live");
        assert!(l.short.is_some() && l.long.is_some());
        // "Now" after it: nothing to report rather than numbers describing a
        // window that no longer exists.
        assert!(
            for_slot(paths.codex_dir(), PAST_RESET as u64 + 1, 10 * 86400).is_none(),
            "a reset window is not reported as used"
        );
    }

    #[test]
    fn the_newest_transcript_that_has_limits_wins() {
        let d = tempfile::tempdir().unwrap();
        let sessions = d.path().join(".codex/sessions/2026/07/27");
        std::fs::create_dir_all(&sessions).unwrap();
        write_transcript(&sessions, "old.jsonl", 10.0, None);
        // A second of separation makes the mtime order unambiguous without
        // needing a crate to backdate a file.
        std::thread::sleep(std::time::Duration::from_millis(1100));
        write_transcript(&sessions, "new.jsonl", 55.0, None);
        let now = std::time::SystemTime::now();
        let paths = Paths::rooted(d.path());
        let secs = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
        let got = for_slot(paths.codex_dir(), secs, 86400).expect("found");
        assert_eq!(got.short.unwrap().used_pct, 55.0, "the newest one wins");
        // A transcript older than the window is not consulted at all.
        assert!(for_slot(paths.codex_dir(), secs + 200_000, 3600).is_none());
    }
}

#[cfg(test)]
mod streaming_read_tests {
    use super::*;
    use std::io::Write;

    /// A transcript must be scanned, not swallowed.
    ///
    /// `from_transcript` read the whole file into a String to find a handful of
    /// lines carrying `rate_limits`. On a Mac with 82 GB of Codex sessions the
    /// largest is 1.1 GB, so the dashboard - which refreshes every 45 seconds -
    /// spiked from 8 MB to 2.1 GB each time, on a machine already 5 GB into
    /// swap. The same code on a machine with small transcripts sat at 44 MB:
    /// same code, different data.
    /// Peak memory while scanning a real ~1 GB transcript, so the claim is
    /// measured rather than argued. Ignored by default: it needs the fixture.
    #[test]
    #[ignore]
    fn scanning_a_gigabyte_stays_small() {
        let p = std::path::Path::new(
            "/tmp/claude-1000/-mnt-d-MyProject-gitstar/c3f27d4f-e0fc-4fb7-861d-89887f526f54/scratchpad/big/.codex/sessions/rollout-big.jsonl",
        );
        if !p.exists() {
            return;
        }
        let got = scan_transcript(p);
        assert!(got.is_some(), "the reading at the end is still found");
    }

    #[test]
    fn a_huge_transcript_is_scanned_line_by_line() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("rollout.jsonl");
        let mut f = std::fs::File::create(&p).unwrap();
        // Bulk that must never be resident all at once, then the line that matters.
        for _ in 0..2000 {
            writeln!(f, "{{\"noise\":\"{}\"}}", "x".repeat(400)).unwrap();
        }
        writeln!(
            f,
            r#"{{"rate_limits":{{"secondary":{{"used_percent":40.0,"window_minutes":10080,"resets_in_seconds":600}}}}}}"#
        )
        .unwrap();
        drop(f);

        let got = scan_transcript(&p).expect("the reading is found by scanning");
        assert!(
            got.long.is_some(),
            "the window at the end of a long file is still found: {got:?}"
        );
    }
}