Skip to content

Commit 66837d9

Browse files
committed
rc.5
1 parent 7a53c2d commit 66837d9

9 files changed

Lines changed: 190 additions & 41 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "headroom-desktop",
3-
"version": "0.4.3-rc.4",
3+
"version": "0.4.3-rc.5",
44
"private": true,
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "headroom-desktop"
3-
version = "0.4.3-rc.4"
3+
version = "0.4.3-rc.5"
44
description = "Headroom v1 local-first LLM optimization tray app"
55
authors = ["Codex"]
66
license = "MIT"

src-tauri/python/headroom-linux-requirements.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# and cause bootstrap to fail on fresh machines.
55
#
66
# Versions kept in sync with headroom-requirements.lock (0.25.0 freeze on
7-
# 2026-06-12) where the package overlaps. We do not currently ship Linux from
7+
# 2026-06-12, reused unchanged for 0.26.0) where the package overlaps. We do not currently ship Linux from
88
# release-macos.yml; when Linux is added, re-validate this lock against a real
99
# Linux minimal install before relying on it.
1010
ast-grep-cli==0.42.2

src-tauri/python/headroom-requirements.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Reviewed known-good Headroom runtime lock.
22
# Source: headroom-ai[all]==0.25.0 dependency resolution captured on 2026-06-12 for cp312.
3+
# Reused unchanged for headroom-ai 0.26.0: its requires_dist is byte-identical
4+
# to 0.25.0, so this resolution remains valid and no pin moves.
35
absl-py==2.4.0
46
aiohappyeyeballs==2.6.2
57
aiohttp==3.14.1

src-tauri/src/client_adapters.rs

Lines changed: 162 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,7 @@ fn remove_legacy_vscode_base_url_keys() -> Result<(Vec<String>, Vec<String>)> {
13941394
}
13951395

13961396
fn codex_config_toml_path() -> PathBuf {
1397-
home_dir().join(".codex").join("config.toml")
1397+
codex_home().join("config.toml")
13981398
}
13991399

14001400
// The managed Codex config is split across two marker blocks so each lands in
@@ -1423,14 +1423,50 @@ const CODEX_TABLE_BLOCK_ID: &str = "codex_cli_provider";
14231423
const CODEX_HEADROOM_PROVIDER: &str = "headroom";
14241424
const CODEX_NATIVE_PROVIDER: &str = "openai";
14251425

1426-
/// Both known Codex state stores: the v148 GUI reads
1427-
/// `~/.codex/sqlite/state_5.sqlite`, the CLI/TUI uses `~/.codex/state_5.sqlite`.
1428-
fn codex_state_db_paths() -> Vec<PathBuf> {
1429-
let codex = home_dir().join(".codex");
1430-
vec![
1431-
codex.join("sqlite").join("state_5.sqlite"),
1432-
codex.join("state_5.sqlite"),
1433-
]
1426+
/// Codex store-schema versions this build has been verified against. Discovered
1427+
/// stores with a version outside this set are still retagged (best-effort) but
1428+
/// logged, so a Codex store bump is visible before it can silently split the
1429+
/// history menu for everyone.
1430+
const KNOWN_CODEX_STORE_VERSIONS: &[u32] = &[5];
1431+
1432+
/// Directories Codex is known to keep its state store in: the v148 GUI uses
1433+
/// `<codex_home>/sqlite/`, the CLI/TUI uses `<codex_home>/`.
1434+
fn codex_state_dirs() -> Vec<PathBuf> {
1435+
let codex = codex_home();
1436+
vec![codex.join("sqlite"), codex]
1437+
}
1438+
1439+
/// Parse `N` from a `state_<N>.sqlite` filename (`state_5.sqlite` -> `Some(5)`).
1440+
/// Anything else -> `None`.
1441+
fn codex_store_version(path: &Path) -> Option<u32> {
1442+
let name = path.file_name()?.to_str()?;
1443+
name.strip_prefix("state_")?.strip_suffix(".sqlite")?.parse().ok()
1444+
}
1445+
1446+
/// Discover every `state_<N>.sqlite` store under the known Codex dirs, with the
1447+
/// version parsed from its name. Scanning the directories (rather than probing a
1448+
/// hardcoded `state_5.sqlite`) means a future Codex store-version bump keeps
1449+
/// working without a release instead of silently no-opping for every user at
1450+
/// once. A missing dir (`read_dir` error) is skipped. Paths are deduped in case
1451+
/// the two dirs ever resolve to the same place.
1452+
fn discover_codex_state_dbs() -> Vec<(PathBuf, u32)> {
1453+
let mut out = Vec::new();
1454+
let mut seen = BTreeSet::new();
1455+
for dir in codex_state_dirs() {
1456+
let Ok(entries) = std::fs::read_dir(&dir) else {
1457+
continue;
1458+
};
1459+
for entry in entries.flatten() {
1460+
let path = entry.path();
1461+
let Some(version) = codex_store_version(&path) else {
1462+
continue;
1463+
};
1464+
if seen.insert(path.clone()) {
1465+
out.push((path, version));
1466+
}
1467+
}
1468+
}
1469+
out
14341470
}
14351471

14361472
/// Best-effort retag of Codex thread provider tags so the history menu stays
@@ -1439,9 +1475,30 @@ fn codex_state_db_paths() -> Vec<PathBuf> {
14391475
/// and skipped. Only rows whose `model_provider` equals `from` are touched, so
14401476
/// third-party providers are left alone.
14411477
fn retag_codex_thread_providers(from: &str, to: &str) {
1442-
for path in codex_state_db_paths() {
1443-
if !path.exists() {
1444-
continue;
1478+
let stores = discover_codex_state_dbs();
1479+
if stores.is_empty() {
1480+
// Only a signal when Codex is actually present: the launch/quit
1481+
// lifecycle hooks call this for every user, so a clean machine with no
1482+
// Codex install must stay silent.
1483+
if codex_user_state_exists() {
1484+
log::warn!(
1485+
"codex retag {from}->{to}: Codex is present but no state_<N>.sqlite \
1486+
store was found under {dirs:?}; the history menu may split. Codex \
1487+
may have moved or renamed its store.",
1488+
dirs = codex_state_dirs(),
1489+
);
1490+
}
1491+
return;
1492+
}
1493+
for (path, version) in stores {
1494+
if !KNOWN_CODEX_STORE_VERSIONS.contains(&version) {
1495+
log::warn!(
1496+
"codex retag: store version {version} at {} is outside the known \
1497+
set {KNOWN_CODEX_STORE_VERSIONS:?}; retagging anyway. Verify the \
1498+
history menu still works and add {version} to \
1499+
KNOWN_CODEX_STORE_VERSIONS.",
1500+
path.display(),
1501+
);
14451502
}
14461503
match retag_one_codex_db(&path, from, to) {
14471504
Ok(0) => {}
@@ -1507,7 +1564,7 @@ fn codex_root_keys_body() -> String {
15071564
/// key), read from `~/.codex/auth.json`. Drives whether the managed provider
15081565
/// block carries `requires_openai_auth = true` (see [`codex_provider_table_body`]).
15091566
fn codex_uses_chatgpt_auth() -> bool {
1510-
let path = home_dir().join(".codex").join("auth.json");
1567+
let path = codex_home().join("auth.json");
15111568
let Ok(raw) = std::fs::read_to_string(&path) else {
15121569
return false;
15131570
};
@@ -2258,6 +2315,17 @@ fn home_dir() -> PathBuf {
22582315
.unwrap_or_else(|| std::env::temp_dir())
22592316
}
22602317

2318+
/// Codex's home directory. Mirrors the Codex CLI and the upstream Headroom
2319+
/// proxy: honor `$CODEX_HOME` when set, else `~/.codex`. Staying in sync with
2320+
/// the proxy matters — if the two layers disagree on where Codex lives, the
2321+
/// provider retag rewrites a different store than the config it edited.
2322+
fn codex_home() -> PathBuf {
2323+
std::env::var_os("CODEX_HOME")
2324+
.filter(|v| !v.is_empty())
2325+
.map(PathBuf::from)
2326+
.unwrap_or_else(|| home_dir().join(".codex"))
2327+
}
2328+
22612329
fn detect_claude_code_client(configured: bool) -> ClientStatus {
22622330
let executable = claude_code_candidate_paths()
22632331
.into_iter()
@@ -2410,7 +2478,8 @@ fn detect_codex_client(configured: bool) -> ClientStatus {
24102478
.as_ref()
24112479
.map(|path| format!("Detected at {}", path.display()))
24122480
.or_else(|| {
2413-
codex_user_state_exists(&home_dir()).then(|| "Detected Codex data in ~/.codex.".into())
2481+
codex_user_state_exists()
2482+
.then(|| format!("Detected Codex data in {}.", codex_home().display()))
24142483
});
24152484

24162485
if let Some(detected_note) = detected {
@@ -2471,8 +2540,8 @@ fn codex_candidate_paths() -> Vec<PathBuf> {
24712540
dedupe_paths(candidates)
24722541
}
24732542

2474-
fn codex_user_state_exists(home: &Path) -> bool {
2475-
let codex_root = home.join(".codex");
2543+
fn codex_user_state_exists() -> bool {
2544+
let codex_root = codex_home();
24762545
codex_root.join("config.toml").exists()
24772546
|| codex_root.join("auth.json").exists()
24782547
|| codex_root.join("sessions").exists()
@@ -2492,7 +2561,7 @@ pub(crate) fn detect_codex_cli() -> Option<PathBuf> {
24922561
/// OAuth token lands in `~/.codex/auth.json`. Required for the keyless
24932562
/// `codex exec` analysis backend.
24942563
pub(crate) fn codex_logged_in() -> bool {
2495-
home_dir().join(".codex").join("auth.json").is_file()
2564+
codex_home().join("auth.json").is_file()
24962565
}
24972566

24982567
fn parse_json_object(raw: &str, path: &Path) -> Result<serde_json::Map<String, Value>> {
@@ -2559,7 +2628,7 @@ fn windows_path_extensions() -> Vec<String> {
25592628

25602629
#[cfg(test)]
25612630
mod tests {
2562-
use std::collections::BTreeMap;
2631+
use std::collections::{BTreeMap, BTreeSet};
25632632
use std::fs;
25642633
use std::path::{Path, PathBuf};
25652634
use std::time::{SystemTime, UNIX_EPOCH};
@@ -2570,8 +2639,9 @@ mod tests {
25702639
build_headroom_rtk_hook, claude_code_user_state_exists, claude_hook_present_in_value,
25712640
default_shell_targets_for_family, entry_contains_hook, find_on_path_entries,
25722641
normalize_setup_state, normalized_setup_id, nvm_binary_candidates, parse_json_object,
2573-
remove_managed_block, retag_codex_thread_providers, retag_codex_threads_to_headroom,
2574-
retag_one_codex_db, serialize_paths, shell_block_contains_in_files,
2642+
codex_home, codex_store_version, discover_codex_state_dbs, remove_managed_block,
2643+
retag_codex_thread_providers, retag_codex_threads_to_headroom, retag_one_codex_db,
2644+
serialize_paths, shell_block_contains_in_files,
25752645
shell_block_contains_text_in_files, shell_double_quote, strip_headroom_hook_from_settings,
25762646
upsert_managed_block, write_file_if_changed, ClientSetupState, ShellFamily,
25772647
};
@@ -3358,6 +3428,7 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33583428
prev_home: Option<std::ffi::OsString>,
33593429
prev_xdg: Option<std::ffi::OsString>,
33603430
prev_shell: Option<std::ffi::OsString>,
3431+
prev_codex: Option<std::ffi::OsString>,
33613432
}
33623433

33633434
impl TestHome {
@@ -3367,11 +3438,15 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33673438
let prev_home = std::env::var_os("HOME");
33683439
let prev_xdg = std::env::var_os("XDG_DATA_HOME");
33693440
let prev_shell = std::env::var_os("SHELL");
3441+
let prev_codex = std::env::var_os("CODEX_HOME");
33703442
std::env::set_var("HOME", &home);
33713443
std::env::set_var("XDG_DATA_HOME", home.join(".local").join("share"));
33723444
// Force a deterministic shell family so tests don't depend on the
33733445
// dev's login shell.
33743446
std::env::set_var("SHELL", "/bin/zsh");
3447+
// Clear any real CODEX_HOME so codex_home() falls back to the temp
3448+
// $HOME/.codex and the Codex tests stay hermetic on dev machines.
3449+
std::env::remove_var("CODEX_HOME");
33753450
// Mirror what the app does at startup so write_setup_state has a
33763451
// config dir to land in.
33773452
crate::storage::ensure_data_dirs(&crate::storage::app_data_dir())
@@ -3382,6 +3457,7 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33823457
prev_home,
33833458
prev_xdg,
33843459
prev_shell,
3460+
prev_codex,
33853461
}
33863462
}
33873463

@@ -3404,6 +3480,10 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
34043480
Some(v) => std::env::set_var("SHELL", v),
34053481
None => std::env::remove_var("SHELL"),
34063482
}
3483+
match self.prev_codex.take() {
3484+
Some(v) => std::env::set_var("CODEX_HOME", v),
3485+
None => std::env::remove_var("CODEX_HOME"),
3486+
}
34073487
}
34083488
}
34093489

@@ -4079,4 +4159,66 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
40794159
// Third-party threads are untouched.
40804160
assert_eq!(provider_count(&db, "anthropic"), 1);
40814161
}
4162+
4163+
#[test]
4164+
fn codex_store_version_parses_state_filename() {
4165+
assert_eq!(codex_store_version(Path::new("/x/state_5.sqlite")), Some(5));
4166+
assert_eq!(codex_store_version(Path::new("/x/state_42.sqlite")), Some(42));
4167+
assert_eq!(codex_store_version(Path::new("/x/config.toml")), None);
4168+
assert_eq!(codex_store_version(Path::new("/x/state_.sqlite")), None);
4169+
assert_eq!(codex_store_version(Path::new("/x/state_x.sqlite")), None);
4170+
assert_eq!(codex_store_version(Path::new("/x/state_5.db")), None);
4171+
}
4172+
4173+
#[test]
4174+
#[serial_test::serial]
4175+
fn codex_home_honors_env_else_default() {
4176+
let home = TestHome::new();
4177+
// TestHome clears CODEX_HOME, so we fall back to $HOME/.codex.
4178+
assert_eq!(codex_home(), home.path().join(".codex"));
4179+
4180+
let custom = home.path().join("custom-codex");
4181+
std::env::set_var("CODEX_HOME", &custom);
4182+
assert_eq!(codex_home(), custom);
4183+
4184+
// An empty value is ignored (treated as unset).
4185+
std::env::set_var("CODEX_HOME", "");
4186+
assert_eq!(codex_home(), home.path().join(".codex"));
4187+
}
4188+
4189+
#[test]
4190+
#[serial_test::serial]
4191+
fn discover_codex_state_dbs_finds_versioned_stores() {
4192+
let home = TestHome::new();
4193+
let codex = home.path().join(".codex");
4194+
std::fs::create_dir_all(codex.join("sqlite")).unwrap();
4195+
// GUI store under sqlite/, CLI store at the root, on different versions.
4196+
std::fs::File::create(codex.join("sqlite").join("state_6.sqlite")).unwrap();
4197+
std::fs::File::create(codex.join("state_5.sqlite")).unwrap();
4198+
// A non-store file in the same dir must be ignored.
4199+
std::fs::File::create(codex.join("config.toml")).unwrap();
4200+
4201+
let versions: BTreeSet<u32> = discover_codex_state_dbs()
4202+
.into_iter()
4203+
.map(|(_, v)| v)
4204+
.collect();
4205+
assert_eq!(versions, BTreeSet::from([5, 6]));
4206+
}
4207+
4208+
#[test]
4209+
#[serial_test::serial]
4210+
fn retag_handles_unknown_store_version() {
4211+
// Future-proofing: a Codex store-version bump (here state_99) must still
4212+
// retag, not silently no-op for every user at once.
4213+
let home = TestHome::new();
4214+
let db = home.path().join(".codex").join("state_99.sqlite");
4215+
std::fs::create_dir_all(db.parent().unwrap()).unwrap();
4216+
seed_codex_threads_db(&db, &[("a", "openai"), ("b", "openai"), ("c", "anthropic")]);
4217+
4218+
retag_codex_threads_to_headroom();
4219+
4220+
assert_eq!(provider_count(&db, "headroom"), 2);
4221+
assert_eq!(provider_count(&db, "openai"), 0);
4222+
assert_eq!(provider_count(&db, "anthropic"), 1);
4223+
}
40824224
}

0 commit comments

Comments
 (0)