From 3d9e3b925c01c73f6aebb85969d0409cdc3341c9 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:07:26 +0100 Subject: [PATCH 01/15] feat(themes): add tokyo-night-storm and tango-adapted Two new built-in palettes down-mapped to the 18-field Rust Palette: tokyo-night-storm (dark, lighter surfaces than base tokyo-night) and tango-adapted (light). Registered in THEME_NAMES + palette_for_theme resolver. Co-authored-by: Isaac --- apps/tui-rs/tests/theme.rs | 20 ++++++++++- packages/sidebar-core-rs/src/renderer.rs | 46 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/tui-rs/tests/theme.rs b/apps/tui-rs/tests/theme.rs index 30a137f..b8bde34 100644 --- a/apps/tui-rs/tests/theme.rs +++ b/apps/tui-rs/tests/theme.rs @@ -2,7 +2,7 @@ use opensessions_sidebar::app::App; use opensessions_sidebar::generated::protocol::{ ClientCommand, ServerMessage, ServerState, SessionFilterMode, }; -use opensessions_sidebar::renderer::palette_for_theme; +use opensessions_sidebar::renderer::{palette_for_theme, Rgb}; use opensessions_sidebar::snapshot::{buffer_to_ansi, render_to_buffer}; fn empty_state(theme: Option<&str>) -> ServerState { @@ -107,3 +107,21 @@ fn rendered_output_uses_active_theme_palette_for_focused_session_text() { "latte rendering must emit the latte text color SGR escape; got latte text fg: {latte_text_sgr:?}" ); } + +#[test] +fn tokyo_night_storm_resolves_to_distinct_palette() { + let storm = palette_for_theme(Some("tokyo-night-storm")); + let night = palette_for_theme(Some("tokyo-night")); + let default = palette_for_theme(None); + assert_ne!(storm, default, "tokyo-night-storm must be a real entry, not the default fallback"); + assert_ne!(storm, night, "tokyo-night-storm must differ from base tokyo-night"); +} + +#[test] +fn tango_adapted_is_a_distinct_light_palette() { + let tango = palette_for_theme(Some("tango-adapted")); + let default = palette_for_theme(None); + assert_ne!(tango, default, "tango-adapted must be a real entry, not the default fallback"); + // Light theme: near-black text on a light background. + assert_eq!(tango.text, Rgb::new(0, 0, 0), "tango-adapted text is black"); +} diff --git a/packages/sidebar-core-rs/src/renderer.rs b/packages/sidebar-core-rs/src/renderer.rs index e9f6d95..2acc91d 100644 --- a/packages/sidebar-core-rs/src/renderer.rs +++ b/packages/sidebar-core-rs/src/renderer.rs @@ -1838,6 +1838,48 @@ const SHADES_OF_PURPLE: Palette = Palette { surface2: Rgb::new(45, 43, 85), }; +const TOKYO_NIGHT_STORM: Palette = Palette { + white: Rgb::new(255, 255, 255), + black: Rgb::new(0, 0, 0), + blue: Rgb::new(122, 162, 247), + lavender: Rgb::new(187, 154, 247), + pink: Rgb::new(187, 154, 247), + yellow: Rgb::new(224, 175, 104), + green: Rgb::new(158, 206, 106), + red: Rgb::new(247, 118, 142), + peach: Rgb::new(255, 158, 100), + teal: Rgb::new(115, 218, 202), + sky: Rgb::new(125, 207, 255), + text: Rgb::new(192, 202, 245), + subtext0: Rgb::new(169, 177, 214), + subtext1: Rgb::new(154, 165, 206), + overlay0: Rgb::new(78, 85, 117), + overlay1: Rgb::new(59, 66, 97), + surface1: Rgb::new(52, 58, 82), + surface2: Rgb::new(65, 72, 104), +}; + +const TANGO_ADAPTED: Palette = Palette { + white: Rgb::new(255, 255, 255), + black: Rgb::new(0, 0, 0), + blue: Rgb::new(0, 162, 255), + lavender: Rgb::new(193, 126, 204), + pink: Rgb::new(233, 167, 225), + yellow: Rgb::new(227, 190, 0), + green: Rgb::new(89, 214, 0), + red: Rgb::new(255, 0, 0), + peach: Rgb::new(206, 92, 0), + teal: Rgb::new(0, 208, 214), + sky: Rgb::new(136, 201, 255), + text: Rgb::new(0, 0, 0), + subtext0: Rgb::new(60, 60, 60), + subtext1: Rgb::new(85, 85, 85), + overlay0: Rgb::new(143, 146, 139), + overlay1: Rgb::new(192, 197, 187), + surface1: Rgb::new(220, 220, 220), + surface2: Rgb::new(200, 200, 200), +}; + /// All built-in theme names, in display order. Used by the theme picker. pub const THEME_NAMES: &[&str] = &[ "catppuccin-mocha", @@ -1860,6 +1902,8 @@ pub const THEME_NAMES: &[&str] = &[ "matrix", "transparent", "shades-of-purple", + "tokyo-night-storm", + "tango-adapted", ]; /// Resolve a theme name to a built-in [`Palette`]. Unknown or missing names @@ -1886,6 +1930,8 @@ pub fn palette_for_theme(name: Option<&str>) -> Palette { Some("matrix") => MATRIX, Some("transparent") => TRANSPARENT, Some("shades-of-purple") => SHADES_OF_PURPLE, + Some("tokyo-night-storm") => TOKYO_NIGHT_STORM, + Some("tango-adapted") => TANGO_ADAPTED, _ => CATPPUCCIN_MOCHA, } } From 20133b8f3a485f209151c15ec4d0e617636c26b3 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:09:47 +0100 Subject: [PATCH 02/15] feat(tracker): hard-prune unseen terminal agents after 15min Two-tier prune_terminal: seen terminal instances prune at 5min (unchanged), unseen at a 15min hard cap (previously never pruned, so they accumulated forever). Clears unseen_instances on prune. Co-authored-by: Isaac --- packages/runtime-rs/src/tracker.rs | 26 +++++++++-- packages/runtime-rs/tests/tracker.rs | 65 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/runtime-rs/src/tracker.rs b/packages/runtime-rs/src/tracker.rs index 214ce49..8766494 100644 --- a/packages/runtime-rs/src/tracker.rs +++ b/packages/runtime-rs/src/tracker.rs @@ -4,6 +4,10 @@ use crate::protocol::{AgentEvent, AgentLiveness, AgentStatus}; const MAX_EVENT_TIMESTAMPS: usize = 30; const TERMINAL_PRUNE_MS: u64 = 5 * 60 * 1000; +/// Hard cap for unseen terminal instances. They are normally kept (so the user +/// can notice them) but must not accumulate forever, so they are pruned after +/// this longer interval even while still unseen. +const TERMINAL_HARD_PRUNE_MS: u64 = 15 * 60 * 1000; const SYNTHETIC_PANE_MARKER: &str = ":pane:"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -214,22 +218,36 @@ impl AgentTracker { for session in sessions { let unseen_instances = self.unseen_instances.clone(); let mut empty = false; + let mut cleared_unseen: Vec = Vec::new(); if let Some(session_instances) = self.instances.get_mut(&session) { let keys = session_instances .iter() .filter(|(key, event)| { - is_terminal_status(event.status) - && !unseen_instances.contains(&format!("{session}\0{key}")) - && event.liveness != Some(AgentLiveness::Alive) - && now.saturating_sub(event.ts) > TERMINAL_PRUNE_MS + if !is_terminal_status(event.status) + || event.liveness == Some(AgentLiveness::Alive) + { + return false; + } + let age = now.saturating_sub(event.ts); + if unseen_instances.contains(&format!("{session}\0{key}")) { + // Unseen: keep until the longer hard cap. + age > TERMINAL_HARD_PRUNE_MS + } else { + // Seen: prune at the normal interval. + age > TERMINAL_PRUNE_MS + } }) .map(|(key, _)| key.clone()) .collect::>(); for key in keys { session_instances.remove(&key); + cleared_unseen.push(format!("{session}\0{key}")); } empty = session_instances.is_empty(); } + for ukey in cleared_unseen { + self.unseen_instances.remove(&ukey); + } if empty { self.instances.remove(&session); } diff --git a/packages/runtime-rs/tests/tracker.rs b/packages/runtime-rs/tests/tracker.rs index 30ed804..6156439 100644 --- a/packages/runtime-rs/tests/tracker.rs +++ b/packages/runtime-rs/tests/tracker.rs @@ -235,6 +235,71 @@ fn synthetic_pane_entry_merges_when_watcher_event_arrives_for_same_thread() { assert_eq!(agents[0].liveness, Some(AgentLiveness::Alive)); } +#[test] +fn prune_terminal_hard_prunes_unseen_after_15min() { + let mut tracker = AgentTracker::new(); + tracker.apply_event(event_at( + "sess-1", + "claude-code", + AgentStatus::Done, + now_ms() - 16 * 60 * 1000, + )); + assert!(tracker.is_unseen("sess-1")); + + tracker.prune_terminal(); + + assert_eq!( + tracker.get_agents("sess-1"), + Vec::::new(), + "a 16-min-old unseen terminal instance must be hard-pruned" + ); + assert!( + !tracker.is_unseen("sess-1"), + "the unseen flag must be cleared when an instance is hard-pruned" + ); +} + +#[test] +fn prune_terminal_keeps_unseen_under_15min() { + let mut tracker = AgentTracker::new(); + tracker.apply_event(event_at( + "sess-1", + "claude-code", + AgentStatus::Done, + now_ms() - 10 * 60 * 1000, + )); + assert!(tracker.is_unseen("sess-1")); + + tracker.prune_terminal(); + + assert_eq!( + tracker.get_agents("sess-1").len(), + 1, + "a 10-min-old unseen terminal instance must survive (under the 15-min hard cap)" + ); +} + +#[test] +fn prune_terminal_prunes_seen_after_5min() { + let mut tracker = AgentTracker::new(); + tracker.apply_event(event_at( + "sess-1", + "claude-code", + AgentStatus::Done, + now_ms() - 6 * 60 * 1000, + )); + tracker.mark_seen("sess-1"); + assert!(!tracker.is_unseen("sess-1")); + + tracker.prune_terminal(); + + assert_eq!( + tracker.get_agents("sess-1"), + Vec::::new(), + "a 6-min-old seen terminal instance must be pruned at the 5-min tier" + ); +} + fn event(session: &str, agent: &str, status: AgentStatus) -> AgentEvent { event_at(session, agent, status, now_ms()) } From 56fa66b7799f669874b0d1744feef8eb17267197 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:14:35 +0100 Subject: [PATCH 03/15] feat(server): evict stale agent-watcher dedup entries after 15min The unified agent-watcher poll loop kept a last_seen fingerprint cache that was never pruned of vanished sessions, so it grew unbounded on a long-running server. Track last-scan time per key and drop entries unseen for 15min (parity with the TS opencode local-eviction patch, generalized across all agents). Co-authored-by: Isaac --- apps/server-rs/src/lib.rs | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 9cb6b74..0c96dab 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -63,6 +63,9 @@ const GIT_CACHE_TTL_MS: u64 = 5_000; const PORT_POLL_INTERVAL_MS: u64 = 10_000; const RENDERED_SIDEBAR_FRAME_MS: u64 = 16; const AGENT_WATCHER_POLL_MS: u64 = 2_000; +/// Drop an agent-watcher dedup entry after this long without its key appearing +/// in a scan, keeping `last_seen` bounded on a long-running server. +const AGENT_WATCHER_EVICT_MS: u64 = 15 * 60 * 1000; // Mirrors `USER_DRAG_SETTLE_MS` in `packages/runtime/src/server/index.ts`: // once a width-report is accepted the coordinator stays in UserDrag for this // many milliseconds, then the next snapshot tick clears it so the sidebar @@ -1464,6 +1467,7 @@ async fn run_agent_watcher_loop( let mut interval = tokio::time::interval(Duration::from_millis(AGENT_WATCHER_POLL_MS)); interval.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut last_seen = HashMap::::new(); + let mut last_seen_at = HashMap::::new(); loop { tokio::select! { @@ -1477,6 +1481,9 @@ async fn run_agent_watcher_loop( "agent_watcher_loop: tick scanned {} snapshots", snapshots.len() )); + for snapshot in &snapshots { + last_seen_at.insert(agent_watcher_key(snapshot), now); + } for snapshot in snapshots { if snapshot.status == AgentStatus::Idle { continue; @@ -1501,6 +1508,12 @@ async fn run_agent_watcher_loop( )); } } + evict_stale_watcher_keys( + &mut last_seen, + &mut last_seen_at, + now, + AGENT_WATCHER_EVICT_MS, + ); } } } @@ -1535,6 +1548,52 @@ fn agent_watcher_key(snapshot: &AgentWatcherSnapshot) -> String { ) } +/// Drop `last_seen` fingerprints whose key has not appeared in a scan within +/// `evict_ms`. `last_seen_at` records the last scan time per key; both maps are +/// pruned together so the agent-watcher dedup cache stays bounded. +fn evict_stale_watcher_keys( + last_seen: &mut std::collections::HashMap, + last_seen_at: &mut std::collections::HashMap, + now: u64, + evict_ms: u64, +) { + last_seen_at.retain(|_, seen_at| now.saturating_sub(*seen_at) < evict_ms); + last_seen.retain(|key, _| last_seen_at.contains_key(key)); +} + +#[cfg(test)] +mod agent_watcher_eviction_tests { + use super::{evict_stale_watcher_keys, AgentStatus, AgentWatcherFingerprint}; + use std::collections::HashMap; + + fn fingerprint() -> AgentWatcherFingerprint { + AgentWatcherFingerprint { + status: AgentStatus::Running, + thread_name: None, + project_dir: None, + } + } + + #[test] + fn evicts_keys_not_seen_within_window() { + let now = 100 * 60 * 1000; + let evict_ms = 15 * 60 * 1000; + let mut last_seen = HashMap::new(); + last_seen.insert("stale".to_string(), fingerprint()); + last_seen.insert("fresh".to_string(), fingerprint()); + let mut last_seen_at = HashMap::new(); + last_seen_at.insert("stale".to_string(), now - 16 * 60 * 1000); + last_seen_at.insert("fresh".to_string(), now - 5 * 60 * 1000); + + evict_stale_watcher_keys(&mut last_seen, &mut last_seen_at, now, evict_ms); + + assert!(!last_seen.contains_key("stale"), "stale key dropped from last_seen"); + assert!(!last_seen_at.contains_key("stale"), "stale key dropped from last_seen_at"); + assert!(last_seen.contains_key("fresh"), "fresh key retained in last_seen"); + assert!(last_seen_at.contains_key("fresh"), "fresh timestamp retained"); + } +} + fn scan_agent_watcher_snapshots(now_ms: u64) -> Vec { let mut snapshots = Vec::new(); let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { From 6cefa9073fd53b3df7e51d98aed9eaf9ba7833c7 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:16:16 +0100 Subject: [PATCH 04/15] feat(config): add autoThemeFollowsSystem/darkTheme/lightTheme Config fields for the macOS auto theme-follow feature, persisted via update_map so save_config_to_home writes them. Co-authored-by: Isaac --- packages/runtime-rs/src/config.rs | 16 +++++++++++++ .../runtime-rs/tests/config_and_shared.rs | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/runtime-rs/src/config.rs b/packages/runtime-rs/src/config.rs index 18f8d46..cbcc76d 100644 --- a/packages/runtime-rs/src/config.rs +++ b/packages/runtime-rs/src/config.rs @@ -36,6 +36,15 @@ pub struct OpensessionsConfig { pub detail_panel_heights: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_filter: Option, + /// macOS only: automatically follow the system Appearance and switch themes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_theme_follows_system: Option, + /// Theme applied when the macOS system Appearance is Dark (default: catppuccin-mocha). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dark_theme: Option, + /// Theme applied when the macOS system Appearance is Light (default: catppuccin-latte). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub light_theme: Option, } pub fn config_path_from_home(home: &Path) -> PathBuf { @@ -105,6 +114,13 @@ fn update_map(updates: OpensessionsConfig) -> Map { ); } insert_option(&mut map, "sessionFilter", updates.session_filter); + insert_option( + &mut map, + "autoThemeFollowsSystem", + updates.auto_theme_follows_system, + ); + insert_option(&mut map, "darkTheme", updates.dark_theme); + insert_option(&mut map, "lightTheme", updates.light_theme); map } diff --git a/packages/runtime-rs/tests/config_and_shared.rs b/packages/runtime-rs/tests/config_and_shared.rs index 484bde4..999a529 100644 --- a/packages/runtime-rs/tests/config_and_shared.rs +++ b/packages/runtime-rs/tests/config_and_shared.rs @@ -161,6 +161,30 @@ fn save_config_merges_with_existing_file_and_preserves_detail_heights() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn config_roundtrips_auto_theme_fields() { + let home = temp_home("auto-theme"); + fs::create_dir_all(home.join(".config/opensessions")).unwrap(); + + save_config_to_home( + &home, + OpensessionsConfig { + auto_theme_follows_system: Some(true), + dark_theme: Some("tokyo-night-storm".into()), + light_theme: Some("tango-adapted".into()), + ..Default::default() + }, + ) + .unwrap(); + + let config = load_config_from_home(&home); + assert_eq!(config.auto_theme_follows_system, Some(true)); + assert_eq!(config.dark_theme.as_deref(), Some("tokyo-night-storm")); + assert_eq!(config.light_theme.as_deref(), Some("tango-adapted")); + + fs::remove_dir_all(home).unwrap(); +} + fn temp_home(name: &str) -> PathBuf { std::env::temp_dir().join(format!( "opensessions-runtime-rs-{name}-{}", From 66377f8b041bcb691a6bd1d062fcd9715fa2fc2b Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:18:38 +0100 Subject: [PATCH 05/15] feat(runtime): system_theme appearance read + pure theme mapping macOS-gated read_mac_system_appearance (defaults read -g AppleInterfaceStyle) and the pure theme_for_system_mode mapping. Non-macOS returns Light. Co-authored-by: Isaac --- packages/runtime-rs/src/lib.rs | 1 + packages/runtime-rs/src/system_theme.rs | 53 +++++++++++++++++++++++ packages/runtime-rs/tests/system_theme.rs | 34 +++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 packages/runtime-rs/src/system_theme.rs create mode 100644 packages/runtime-rs/tests/system_theme.rs diff --git a/packages/runtime-rs/src/lib.rs b/packages/runtime-rs/src/lib.rs index 431de18..18c968a 100644 --- a/packages/runtime-rs/src/lib.rs +++ b/packages/runtime-rs/src/lib.rs @@ -14,6 +14,7 @@ pub mod session_order; pub mod shared; pub mod sidebar_coordinator; pub mod sidebar_width_sync; +pub mod system_theme; pub mod tmux_provider; pub mod tracker; pub mod watch_plan; diff --git a/packages/runtime-rs/src/system_theme.rs b/packages/runtime-rs/src/system_theme.rs new file mode 100644 index 0000000..6db4bc1 --- /dev/null +++ b/packages/runtime-rs/src/system_theme.rs @@ -0,0 +1,53 @@ +//! macOS system-appearance helpers. +//! +//! Parity with the TypeScript `system-theme` module: detect the macOS Appearance +//! (Light/Dark), map it to a configured theme name, and (see [`watch_mac_system_appearance`]) +//! push changes to the consumer. All functions are total and macOS-gated; on +//! non-macOS platforms appearance is always [`SystemAppearance::Light`]. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemAppearance { + Dark, + Light, +} + +/// Map a detected appearance + configured theme names to the theme to apply. +/// Pure and trivially testable. +pub fn theme_for_system_mode( + mode: SystemAppearance, + dark_theme: &str, + light_theme: &str, +) -> String { + match mode { + SystemAppearance::Dark => dark_theme.to_string(), + SystemAppearance::Light => light_theme.to_string(), + } +} + +/// Read the current macOS Appearance. `defaults read -g AppleInterfaceStyle` +/// prints "Dark" in dark mode and exits non-zero / empty in light mode (the key +/// is absent), so both absent and unreadable map to Light. Never panics. +#[cfg(target_os = "macos")] +pub fn read_mac_system_appearance() -> SystemAppearance { + use std::process::Command; + match Command::new("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + { + Ok(output) => { + let value = String::from_utf8_lossy(&output.stdout); + if value.trim() == "Dark" { + SystemAppearance::Dark + } else { + SystemAppearance::Light + } + } + Err(_) => SystemAppearance::Light, + } +} + +/// Non-macOS platforms have no system Appearance; always Light. +#[cfg(not(target_os = "macos"))] +pub fn read_mac_system_appearance() -> SystemAppearance { + SystemAppearance::Light +} diff --git a/packages/runtime-rs/tests/system_theme.rs b/packages/runtime-rs/tests/system_theme.rs new file mode 100644 index 0000000..f37f763 --- /dev/null +++ b/packages/runtime-rs/tests/system_theme.rs @@ -0,0 +1,34 @@ +use opensessions_runtime::system_theme::{ + read_mac_system_appearance, theme_for_system_mode, SystemAppearance, +}; + +#[test] +fn theme_for_system_mode_maps_dark_and_light() { + assert_eq!( + theme_for_system_mode(SystemAppearance::Dark, "catppuccin-mocha", "catppuccin-latte"), + "catppuccin-mocha" + ); + assert_eq!( + theme_for_system_mode(SystemAppearance::Light, "catppuccin-mocha", "catppuccin-latte"), + "catppuccin-latte" + ); +} + +#[test] +fn theme_for_system_mode_respects_custom_names() { + assert_eq!( + theme_for_system_mode(SystemAppearance::Dark, "tokyo-night-storm", "tango-adapted"), + "tokyo-night-storm" + ); + assert_eq!( + theme_for_system_mode(SystemAppearance::Light, "tokyo-night-storm", "tango-adapted"), + "tango-adapted" + ); +} + +#[test] +fn read_appearance_is_total_and_returns_a_variant() { + // Non-macOS: always Light. macOS: reads the real setting. Never panics. + let mode = read_mac_system_appearance(); + assert!(matches!(mode, SystemAppearance::Dark | SystemAppearance::Light)); +} From 3d417ec8045bfae4be1bb4478290d70eb07c21c7 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 11:23:00 +0100 Subject: [PATCH 06/15] feat(runtime): push-based macOS appearance watcher (notify + 60s safety poll) watch_mac_system_appearance fires on .GlobalPreferences.plist writes via notify (kqueue/FSEvents), suppressing no-op events, with a 60s safety poll for the atomic-rename case. macOS-gated; no-op handle elsewhere. Adds notify v8 as a macOS-target dep. Co-authored-by: Isaac --- Cargo.lock | 189 +++++++++++++++++++++- packages/runtime-rs/Cargo.toml | 3 + packages/runtime-rs/src/system_theme.rs | 118 ++++++++++++++ packages/runtime-rs/tests/system_theme.rs | 21 +++ 4 files changed, 326 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40c6b88..12b5db7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -250,6 +250,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -328,6 +337,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -367,6 +396,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "libc" version = "0.2.186" @@ -433,7 +482,34 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", ] [[package]] @@ -455,6 +531,7 @@ dependencies = [ name = "opensessions-runtime" version = "0.1.0" dependencies = [ + "notify", "serde", "serde_json", ] @@ -668,7 +745,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -683,6 +760,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -794,7 +880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -894,7 +980,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -969,6 +1055,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -991,6 +1087,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1003,6 +1108,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1012,6 +1126,71 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "zmij" version = "1.0.21" diff --git a/packages/runtime-rs/Cargo.toml b/packages/runtime-rs/Cargo.toml index 9605d69..adf4527 100644 --- a/packages/runtime-rs/Cargo.toml +++ b/packages/runtime-rs/Cargo.toml @@ -10,3 +10,6 @@ path = "src/lib.rs" [dependencies] serde = { version = "1", default-features = false, features = ["derive", "alloc"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } + +[target.'cfg(target_os = "macos")'.dependencies] +notify = "8.2.0" diff --git a/packages/runtime-rs/src/system_theme.rs b/packages/runtime-rs/src/system_theme.rs index 6db4bc1..82d57eb 100644 --- a/packages/runtime-rs/src/system_theme.rs +++ b/packages/runtime-rs/src/system_theme.rs @@ -51,3 +51,121 @@ pub fn read_mac_system_appearance() -> SystemAppearance { pub fn read_mac_system_appearance() -> SystemAppearance { SystemAppearance::Light } + +/// Handle for a running appearance watcher. [`stop`](AppearanceWatcher::stop) +/// signals its threads to exit; dropping the handle does not (threads hold their +/// own stop flag), so callers keep it alive for the server's lifetime. +pub struct AppearanceWatcher { + stop: std::sync::Arc, +} + +impl AppearanceWatcher { + /// Idempotent: signals the watcher threads to exit on their next tick. + pub fn stop(&self) { + self.stop + .store(true, std::sync::atomic::Ordering::SeqCst); + } +} + +/// Non-macOS: nothing to watch. Returns a handle whose `stop()` is a no-op. +#[cfg(not(target_os = "macos"))] +pub fn watch_mac_system_appearance( + _on_change: F, + _safety_poll_ms: Option, +) -> AppearanceWatcher +where + F: Fn(SystemAppearance) + Send + Sync + 'static, +{ + AppearanceWatcher { + stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), + } +} + +/// Watch the macOS Appearance and invoke `on_change` whenever it flips. +/// +/// Push-based: watches `~/Library/Preferences/.GlobalPreferences.plist` (which +/// macOS rewrites on any global-preference change) and re-reads appearance on +/// each event, suppressing the callback unless the value actually changed. A +/// safety-poll thread (default 60s) covers the atomic-rename case where the file +/// watch loses the inode. Fires once synchronously with the initial mode. +#[cfg(target_os = "macos")] +pub fn watch_mac_system_appearance( + on_change: F, + safety_poll_ms: Option, +) -> AppearanceWatcher +where + F: Fn(SystemAppearance) + Send + Sync + 'static, +{ + use notify::{RecursiveMode, Watcher}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + let stop = Arc::new(AtomicBool::new(false)); + let last: Arc>> = Arc::new(Mutex::new(None)); + let on_change: Arc = Arc::new(on_change); + + let check = move || { + let mode = read_mac_system_appearance(); + let mut guard = last.lock().unwrap(); + if *guard != Some(mode) { + *guard = Some(mode); + drop(guard); + on_change(mode); + } + }; + + // Fire once so the consumer learns the starting mode without waiting. + check(); + + let plist = home_dir().join("Library/Preferences/.GlobalPreferences.plist"); + + // Push: re-check on any write to the global-preferences plist. + { + let check = check.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let Ok(mut watcher) = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + }) else { + return; + }; + if watcher.watch(&plist, RecursiveMode::NonRecursive).is_err() { + return; + } + while !stop.load(Ordering::SeqCst) { + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(_) => check(), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } + }); + } + + // Safety poll for the atomic-rename case where the file watch goes silent. + { + let check = check.clone(); + let stop = stop.clone(); + let poll = Duration::from_millis(safety_poll_ms.unwrap_or(60_000)); + std::thread::spawn(move || { + while !stop.load(Ordering::SeqCst) { + std::thread::sleep(poll); + if stop.load(Ordering::SeqCst) { + break; + } + check(); + } + }); + } + + AppearanceWatcher { stop } +} + +#[cfg(target_os = "macos")] +fn home_dir() -> std::path::PathBuf { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default() +} diff --git a/packages/runtime-rs/tests/system_theme.rs b/packages/runtime-rs/tests/system_theme.rs index f37f763..961d931 100644 --- a/packages/runtime-rs/tests/system_theme.rs +++ b/packages/runtime-rs/tests/system_theme.rs @@ -32,3 +32,24 @@ fn read_appearance_is_total_and_returns_a_variant() { let mode = read_mac_system_appearance(); assert!(matches!(mode, SystemAppearance::Dark | SystemAppearance::Light)); } + +#[test] +fn watcher_handle_stop_is_idempotent() { + use opensessions_runtime::system_theme::watch_mac_system_appearance; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + let watcher = watch_mac_system_appearance( + move |_mode| { + counter.fetch_add(1, Ordering::SeqCst); + }, + Some(60_000), + ); + watcher.stop(); + watcher.stop(); // must not panic + + // On macOS the initial synchronous check fires once; on non-macOS, never. + assert!(calls.load(Ordering::SeqCst) <= 1); +} From 57519eb6820d7bf6cb210f7ecda3372b2658b185 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 13:59:22 +0100 Subject: [PATCH 07/15] feat(server): auto-switch theme on macOS appearance change Wire the appearance watcher into the server: ReadOnlyMuxStateSource gains an opt-in auto-theme follower (with_auto_theme_follow, set from config in default_state_source_from_env so tests stay hermetic). On each appearance change it re-reads config and applies resolve_auto_theme(mode, config), broadcasting only on a real change. Watcher stops on shutdown. Co-authored-by: Isaac --- apps/server-rs/src/lib.rs | 88 ++++++++++++++++++++++- packages/runtime-rs/src/system_theme.rs | 10 +++ packages/runtime-rs/tests/system_theme.rs | 27 +++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 0c96dab..f56f3ee 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use std::process; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Instant, SystemTime}; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -15,6 +16,7 @@ use opensessions_runtime::agent_watchers::{ codex_snapshot_from_jsonl, codex_thread_id_from_path, decode_claude_project_dir, opencode_snapshot_from_row, parse_codex_session_index, }; +use opensessions_runtime::config::load_config_from_home; use opensessions_runtime::git_info::{GitInfo, parse_git_info_output}; use opensessions_runtime::metadata_store::SessionMetadataStore; use opensessions_runtime::mux::{ActiveWindow, MuxProvider, SidebarPosition}; @@ -31,6 +33,9 @@ use opensessions_runtime::session_order::SessionOrder; use opensessions_runtime::sidebar_coordinator::{SidebarCoordinator, SidebarWidthReportInput}; use opensessions_runtime::sidebar_width_sync::clamp_sidebar_width; use opensessions_runtime::tmux_provider::{StdCommandRunner, TmuxProvider}; +use opensessions_runtime::system_theme::{ + SystemAppearance, resolve_auto_theme, watch_mac_system_appearance, +}; use opensessions_runtime::tracker::{AgentTracker, PanePresenceInput}; use opensessions_sidebar_core::app::App as SidebarApp; use opensessions_sidebar_core::frame::{FrameDiff, RenderedRows, diff_rows, render_rows}; @@ -308,6 +313,8 @@ pub struct ReadOnlyMuxStateSource { sidebar_width: Mutex, focused_session: Mutex>, theme: Mutex>, + current_appearance: Mutex>, + auto_theme_following: AtomicBool, session_filter: Mutex>, session_order: Mutex, metadata_store: Mutex, @@ -325,6 +332,8 @@ pub fn default_state_source_from_env( if let Some(width) = env("OPENSESSIONS_WIDTH").and_then(|width| width.parse::().ok()) { source = source.with_sidebar_width(clamp_sidebar_width(width) as u32); } + let config = load_config_from_home(&server_home_dir()); + source = source.with_auto_theme_follow(config.auto_theme_follows_system.unwrap_or(false)); return Some(source); } @@ -343,6 +352,8 @@ impl ReadOnlyMuxStateSource { sidebar_width: Mutex::new(26), focused_session: Mutex::new(None), theme: Mutex::new(None), + current_appearance: Mutex::new(None), + auto_theme_following: AtomicBool::new(false), session_filter: Mutex::new(None), session_order: Mutex::new(SessionOrder::new(None)), metadata_store: Mutex::new(SessionMetadataStore::new()), @@ -372,6 +383,66 @@ impl ReadOnlyMuxStateSource { self.git_command_runner = runner; self } + + /// Enable the macOS system-appearance theme follower (off by default so + /// tests stay hermetic). The real bootstrap sets this from config. + pub fn with_auto_theme_follow(self, enabled: bool) -> Self { + self.auto_theme_following.store(enabled, Ordering::SeqCst); + self + } + + /// Start the macOS appearance follower when enabled via + /// [`with_auto_theme_follow`](Self::with_auto_theme_follow). Returns a task + /// that stops the watcher on shutdown, or `None` when disabled. macOS-gated + /// via the watcher itself. + fn start_system_theme_follower( + self: Arc, + state_updates: broadcast::Sender, + shutdown: broadcast::Sender<()>, + ) -> Option> { + if !self.auto_theme_following.load(Ordering::SeqCst) { + return None; + } + + let source = self.clone(); + let updates = state_updates.clone(); + let watcher = watch_mac_system_appearance( + move |mode| source.on_system_appearance_change(mode, &updates), + Some(60_000), + ); + + let mut shutdown_rx = shutdown.subscribe(); + Some(tokio::spawn(async move { + let _ = shutdown_rx.recv().await; + watcher.stop(); + })) + } + + /// Apply the configured dark/light theme for a new system appearance, + /// re-reading config each time so a manual override is honored, and + /// broadcast only when the active theme actually changes. + fn on_system_appearance_change( + &self, + mode: SystemAppearance, + state_updates: &broadcast::Sender, + ) { + *self.current_appearance.lock().unwrap() = Some(mode); + let config = load_config_from_home(&server_home_dir()); + let desired = resolve_auto_theme(mode, &config); + let mut theme = self.theme.lock().unwrap(); + if theme.as_deref() == Some(desired.as_str()) { + return; + } + *theme = Some(desired); + drop(theme); + let _ = state_updates.send(self.snapshot_json()); + } +} + +fn server_home_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() } impl StateSource for ReadOnlyMuxStateSource { @@ -380,7 +451,7 @@ impl StateSource for ReadOnlyMuxStateSource { state_updates: broadcast::Sender, shutdown: broadcast::Sender<()>, ) -> Vec> { - vec![ + let mut tasks = vec![ tokio::spawn(run_agent_watcher_loop( self.clone(), state_updates.clone(), @@ -391,8 +462,19 @@ impl StateSource for ReadOnlyMuxStateSource { state_updates.clone(), shutdown.clone(), )), - tokio::spawn(run_tmux_state_poll_loop(self, state_updates, shutdown)), - ] + ]; + if let Some(task) = self + .clone() + .start_system_theme_follower(state_updates.clone(), shutdown.clone()) + { + tasks.push(task); + } + tasks.push(tokio::spawn(run_tmux_state_poll_loop( + self, + state_updates, + shutdown, + ))); + tasks } fn snapshot_json(&self) -> String { diff --git a/packages/runtime-rs/src/system_theme.rs b/packages/runtime-rs/src/system_theme.rs index 82d57eb..8d4fc29 100644 --- a/packages/runtime-rs/src/system_theme.rs +++ b/packages/runtime-rs/src/system_theme.rs @@ -5,6 +5,8 @@ //! push changes to the consumer. All functions are total and macOS-gated; on //! non-macOS platforms appearance is always [`SystemAppearance::Light`]. +use crate::config::OpensessionsConfig; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemAppearance { Dark, @@ -24,6 +26,14 @@ pub fn theme_for_system_mode( } } +/// Resolve the theme to apply for the current appearance from config, using the +/// TS defaults: dark -> catppuccin-mocha, light -> catppuccin-latte. +pub fn resolve_auto_theme(mode: SystemAppearance, config: &OpensessionsConfig) -> String { + let dark = config.dark_theme.as_deref().unwrap_or("catppuccin-mocha"); + let light = config.light_theme.as_deref().unwrap_or("catppuccin-latte"); + theme_for_system_mode(mode, dark, light) +} + /// Read the current macOS Appearance. `defaults read -g AppleInterfaceStyle` /// prints "Dark" in dark mode and exits non-zero / empty in light mode (the key /// is absent), so both absent and unreadable map to Light. Never panics. diff --git a/packages/runtime-rs/tests/system_theme.rs b/packages/runtime-rs/tests/system_theme.rs index 961d931..06c4bec 100644 --- a/packages/runtime-rs/tests/system_theme.rs +++ b/packages/runtime-rs/tests/system_theme.rs @@ -53,3 +53,30 @@ fn watcher_handle_stop_is_idempotent() { // On macOS the initial synchronous check fires once; on non-macOS, never. assert!(calls.load(Ordering::SeqCst) <= 1); } + +#[test] +fn resolve_auto_theme_uses_config_with_defaults() { + use opensessions_runtime::config::OpensessionsConfig; + use opensessions_runtime::system_theme::resolve_auto_theme; + + let mut config = OpensessionsConfig::default(); + assert_eq!( + resolve_auto_theme(SystemAppearance::Dark, &config), + "catppuccin-mocha" + ); + assert_eq!( + resolve_auto_theme(SystemAppearance::Light, &config), + "catppuccin-latte" + ); + + config.dark_theme = Some("tokyo-night-storm".into()); + config.light_theme = Some("tango-adapted".into()); + assert_eq!( + resolve_auto_theme(SystemAppearance::Dark, &config), + "tokyo-night-storm" + ); + assert_eq!( + resolve_auto_theme(SystemAppearance::Light, &config), + "tango-adapted" + ); +} From d7df1773b9aaa557331ccde5cbf830b49c362d9e Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 14:03:53 +0100 Subject: [PATCH 08/15] feat(server): persist manual theme override per system appearance A manual set-theme now persists to config: when following the system appearance it writes the darkTheme/lightTheme slot for the current mode (so dark and light remember independently), otherwise the plain theme slot. Config home is injected (with_config_home) and unset in tests, so set-theme stays hermetic. Co-authored-by: Isaac --- apps/server-rs/src/lib.rs | 54 ++++++++++++++++++++--- packages/runtime-rs/src/system_theme.rs | 26 +++++++++++ packages/runtime-rs/tests/system_theme.rs | 24 ++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index f56f3ee..2a10d6c 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -16,7 +16,9 @@ use opensessions_runtime::agent_watchers::{ codex_snapshot_from_jsonl, codex_thread_id_from_path, decode_claude_project_dir, opencode_snapshot_from_row, parse_codex_session_index, }; -use opensessions_runtime::config::load_config_from_home; +use opensessions_runtime::config::{ + OpensessionsConfig, load_config_from_home, save_config_to_home, +}; use opensessions_runtime::git_info::{GitInfo, parse_git_info_output}; use opensessions_runtime::metadata_store::SessionMetadataStore; use opensessions_runtime::mux::{ActiveWindow, MuxProvider, SidebarPosition}; @@ -34,7 +36,8 @@ use opensessions_runtime::sidebar_coordinator::{SidebarCoordinator, SidebarWidth use opensessions_runtime::sidebar_width_sync::clamp_sidebar_width; use opensessions_runtime::tmux_provider::{StdCommandRunner, TmuxProvider}; use opensessions_runtime::system_theme::{ - SystemAppearance, resolve_auto_theme, watch_mac_system_appearance, + SystemAppearance, ThemePersistSlot, manual_persist_slot, resolve_auto_theme, + watch_mac_system_appearance, }; use opensessions_runtime::tracker::{AgentTracker, PanePresenceInput}; use opensessions_sidebar_core::app::App as SidebarApp; @@ -315,6 +318,7 @@ pub struct ReadOnlyMuxStateSource { theme: Mutex>, current_appearance: Mutex>, auto_theme_following: AtomicBool, + config_home: Option, session_filter: Mutex>, session_order: Mutex, metadata_store: Mutex, @@ -333,7 +337,9 @@ pub fn default_state_source_from_env( source = source.with_sidebar_width(clamp_sidebar_width(width) as u32); } let config = load_config_from_home(&server_home_dir()); - source = source.with_auto_theme_follow(config.auto_theme_follows_system.unwrap_or(false)); + source = source + .with_config_home(server_home_dir()) + .with_auto_theme_follow(config.auto_theme_follows_system.unwrap_or(false)); return Some(source); } @@ -354,6 +360,7 @@ impl ReadOnlyMuxStateSource { theme: Mutex::new(None), current_appearance: Mutex::new(None), auto_theme_following: AtomicBool::new(false), + config_home: None, session_filter: Mutex::new(None), session_order: Mutex::new(SessionOrder::new(None)), metadata_store: Mutex::new(SessionMetadataStore::new()), @@ -391,6 +398,14 @@ impl ReadOnlyMuxStateSource { self } + /// Directory whose `.config/opensessions/config.json` theme changes persist + /// to. Unset (the default) disables persistence so tests do not touch the + /// real config; the real bootstrap sets it to `$HOME`. + pub fn with_config_home(mut self, home: PathBuf) -> Self { + self.config_home = Some(home); + self + } + /// Start the macOS appearance follower when enabled via /// [`with_auto_theme_follow`](Self::with_auto_theme_follow). Returns a task /// that stops the watcher on shutdown, or `None` when disabled. macOS-gated @@ -427,7 +442,10 @@ impl ReadOnlyMuxStateSource { state_updates: &broadcast::Sender, ) { *self.current_appearance.lock().unwrap() = Some(mode); - let config = load_config_from_home(&server_home_dir()); + let Some(home) = self.config_home.clone() else { + return; + }; + let config = load_config_from_home(&home); let desired = resolve_auto_theme(mode, &config); let mut theme = self.theme.lock().unwrap(); if theme.as_deref() == Some(desired.as_str()) { @@ -437,6 +455,31 @@ impl ReadOnlyMuxStateSource { drop(theme); let _ = state_updates.send(self.snapshot_json()); } + + /// Persist a manual theme choice to the appropriate config slot (per current + /// appearance when following). No-op when no config home is configured. + fn persist_theme_choice(&self, theme: &str) { + let Some(home) = self.config_home.clone() else { + return; + }; + let following = self.auto_theme_following.load(Ordering::SeqCst); + let mode = *self.current_appearance.lock().unwrap(); + let updates = match manual_persist_slot(following, mode) { + ThemePersistSlot::DarkTheme => OpensessionsConfig { + dark_theme: Some(theme.to_string()), + ..Default::default() + }, + ThemePersistSlot::LightTheme => OpensessionsConfig { + light_theme: Some(theme.to_string()), + ..Default::default() + }, + ThemePersistSlot::Theme => OpensessionsConfig { + theme: Some(serde_json::json!(theme)), + ..Default::default() + }, + }; + let _ = save_config_to_home(&home, updates); + } } fn server_home_dir() -> PathBuf { @@ -638,7 +681,8 @@ impl StateSource for ReadOnlyMuxStateSource { } "set-theme" => { let theme = command.get("theme")?.as_str()?.to_string(); - *self.theme.lock().unwrap() = Some(theme); + *self.theme.lock().unwrap() = Some(theme.clone()); + self.persist_theme_choice(&theme); Some(self.snapshot_json()) } "set-filter" => { diff --git a/packages/runtime-rs/src/system_theme.rs b/packages/runtime-rs/src/system_theme.rs index 8d4fc29..c00d0b4 100644 --- a/packages/runtime-rs/src/system_theme.rs +++ b/packages/runtime-rs/src/system_theme.rs @@ -34,6 +34,32 @@ pub fn resolve_auto_theme(mode: SystemAppearance, config: &OpensessionsConfig) - theme_for_system_mode(mode, dark, light) } +/// Which config field a manual theme choice should persist to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemePersistSlot { + Theme, + DarkTheme, + LightTheme, +} + +/// When auto-follow is active, a manual theme choice persists to the +/// appearance-specific slot (so the next poll does not overwrite it and dark / +/// light remember independently); otherwise it persists to the plain `theme` +/// slot. Falls back to `theme` if the appearance has not been observed yet. +pub fn manual_persist_slot( + auto_following: bool, + current_mode: Option, +) -> ThemePersistSlot { + if !auto_following { + return ThemePersistSlot::Theme; + } + match current_mode { + Some(SystemAppearance::Dark) => ThemePersistSlot::DarkTheme, + Some(SystemAppearance::Light) => ThemePersistSlot::LightTheme, + None => ThemePersistSlot::Theme, + } +} + /// Read the current macOS Appearance. `defaults read -g AppleInterfaceStyle` /// prints "Dark" in dark mode and exits non-zero / empty in light mode (the key /// is absent), so both absent and unreadable map to Light. Never panics. diff --git a/packages/runtime-rs/tests/system_theme.rs b/packages/runtime-rs/tests/system_theme.rs index 06c4bec..1976459 100644 --- a/packages/runtime-rs/tests/system_theme.rs +++ b/packages/runtime-rs/tests/system_theme.rs @@ -80,3 +80,27 @@ fn resolve_auto_theme_uses_config_with_defaults() { "tango-adapted" ); } + +#[test] +fn manual_persist_slot_picks_per_appearance_when_following() { + use opensessions_runtime::system_theme::{manual_persist_slot, ThemePersistSlot}; + + // Not following → always the plain `theme` slot. + assert_eq!( + manual_persist_slot(false, Some(SystemAppearance::Dark)), + ThemePersistSlot::Theme + ); + assert_eq!(manual_persist_slot(false, None), ThemePersistSlot::Theme); + + // Following → per-appearance slot, so dark/light remember independently. + assert_eq!( + manual_persist_slot(true, Some(SystemAppearance::Dark)), + ThemePersistSlot::DarkTheme + ); + assert_eq!( + manual_persist_slot(true, Some(SystemAppearance::Light)), + ThemePersistSlot::LightTheme + ); + // Following but appearance not observed yet → fall back to `theme`. + assert_eq!(manual_persist_slot(true, None), ThemePersistSlot::Theme); +} From 65e5642877f7dff349de7e6a16415d87aae9adeb Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 14:07:38 +0100 Subject: [PATCH 09/15] feat(server): clean singleton guard on port conflict Before binding, probe the pid file: if it names a live process, print 'another server is already running (pid N). Exiting.' and exit 0 instead of failing with a bare AddrInUse error. A stale pid file (dead process) does not block startup. Pure helpers (parse_pid/pid_is_alive/running_server_pid) are unit-tested; uses libc::kill(pid,0). Co-authored-by: Isaac --- Cargo.lock | 1 + apps/server-rs/Cargo.toml | 1 + apps/server-rs/src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++++ apps/server-rs/src/main.rs | 8 ++++- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 12b5db7..934f081 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,7 @@ dependencies = [ "base64", "futures-util", "http", + "libc", "opensessions-runtime", "opensessions-sidebar-core", "opensessions-sidebar-protocol", diff --git a/apps/server-rs/Cargo.toml b/apps/server-rs/Cargo.toml index 9e09de3..ce3e376 100644 --- a/apps/server-rs/Cargo.toml +++ b/apps/server-rs/Cargo.toml @@ -21,6 +21,7 @@ futures-util = { version = "0.3", default-features = false, features = ["sink"] serde_json = { version = "1", default-features = false, features = ["alloc"] } base64 = { version = "0.22", default-features = false, features = ["alloc"] } sha1_smol = { version = "1", default-features = false } +libc = "0.2.186" [dev-dependencies] http = "1" diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 2a10d6c..9c5130d 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -488,6 +488,67 @@ fn server_home_dir() -> PathBuf { .unwrap_or_default() } +/// Parse a PID from pid-file contents. +pub fn parse_pid(contents: &str) -> Option { + contents.trim().parse().ok() +} + +/// True if a process with `pid` is currently running, via a signal-0 probe +/// (`kill(pid, 0)`): 0 means it exists and is signalable; `EPERM` means it +/// exists but is owned by another user. +pub fn pid_is_alive(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if result == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// If `pid_file` names a live process, return its PID — another opensessions +/// server already owns the port. A missing file or a stale (dead) PID yields +/// `None`, so a crashed server's leftover pid file does not block startup. +pub fn running_server_pid(pid_file: &Path) -> Option { + let contents = fs::read_to_string(pid_file).ok()?; + let pid = parse_pid(&contents)?; + pid_is_alive(pid).then_some(pid) +} + +#[cfg(test)] +mod singleton_tests { + use super::{parse_pid, pid_is_alive, running_server_pid}; + + #[test] + fn parse_pid_trims_and_rejects_garbage() { + assert_eq!(parse_pid(" 1234\n"), Some(1234)); + assert_eq!(parse_pid("not-a-pid"), None); + assert_eq!(parse_pid(""), None); + } + + #[test] + fn pid_is_alive_for_current_process_but_not_for_unused_pid() { + assert!(pid_is_alive(std::process::id())); + assert!(!pid_is_alive(999_999_999)); + } + + #[test] + fn running_server_pid_detects_live_and_ignores_stale() { + let dir = std::env::temp_dir().join(format!("os-singleton-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pid_file = dir.join("server.pid"); + + std::fs::write(&pid_file, std::process::id().to_string()).unwrap(); + assert_eq!(running_server_pid(&pid_file), Some(std::process::id())); + + std::fs::write(&pid_file, "999999999").unwrap(); + assert_eq!(running_server_pid(&pid_file), None); + + std::fs::remove_file(&pid_file).unwrap(); + assert_eq!(running_server_pid(&pid_file), None); + + std::fs::remove_dir_all(&dir).ok(); + } +} + impl StateSource for ReadOnlyMuxStateSource { fn start_background_tasks( self: Arc, diff --git a/apps/server-rs/src/main.rs b/apps/server-rs/src/main.rs index 9a814b0..e8947e2 100644 --- a/apps/server-rs/src/main.rs +++ b/apps/server-rs/src/main.rs @@ -1,9 +1,15 @@ use opensessions_runtime::shared::resolve_server_settings; -use opensessions_server::{ServerConfig, default_state_source_from_env, start_server}; +use opensessions_server::{ + ServerConfig, default_state_source_from_env, running_server_pid, start_server, +}; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let settings = resolve_server_settings(|key| std::env::var(key).ok()); + if let Some(pid) = running_server_pid(std::path::Path::new(&settings.pid_file)) { + eprintln!("opensessions: another server is already running (pid {pid}). Exiting."); + return Ok(()); + } let mut config = ServerConfig::new(settings.host, settings.port, settings.pid_file); if let Some(source) = default_state_source_from_env(|key| std::env::var(key).ok()) { config = config.with_state_source(source); From fedc73838cdf81e76d98df46404821815a2254a4 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 15:08:36 +0100 Subject: [PATCH 10/15] fix(tmux): wrap sidebar spawn command in sh -c for non-POSIX shells spawn_sidebar emitted a POSIX command (FOO=bar exec ...; ${VAR:-default}) that tmux runs via the user's default-command/default-shell. Under a non-POSIX shell (e.g. fish) it fails to parse and the sidebar pane dies with status 127 before start.sh runs. Wrap the command in sh -c '...' (escaping single quotes) so it is interpreted by a POSIX shell regardless of the user's interactive shell. Co-authored-by: Isaac --- packages/runtime-rs/src/tmux_provider.rs | 9 ++++++++- packages/runtime-rs/tests/tmux_provider.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/runtime-rs/src/tmux_provider.rs b/packages/runtime-rs/src/tmux_provider.rs index 9230fe5..a2ccd4d 100644 --- a/packages/runtime-rs/src/tmux_provider.rs +++ b/packages/runtime-rs/src/tmux_provider.rs @@ -652,10 +652,17 @@ impl MuxProvider for TmuxProvider { // pane works even when the parent pane's cwd is unrelated to the // workspace (e.g. tmux sessions whose default cwd is `$HOME`). Falls // back to the literal path if the env is unset. - let command = format!( + // + // Wrap in `sh -c '...'`: tmux runs pane commands via the user's + // `default-command`/`default-shell`, which may be a non-POSIX shell + // (e.g. fish) that cannot parse `FOO=bar exec` or `${VAR:-default}`. + // Forcing `sh` keeps the launcher portable regardless of the user's + // interactive shell. Single quotes in the session name are escaped. + let inner = format!( "OPENSESSIONS_SESSION_NAME={} OPENSESSIONS_WINDOW_ID={window_id} REFOCUS_WINDOW={window_id} exec \"${{OPENSESSIONS_DIR:-.}}\"/{scripts_dir}/start.sh", target.session_name, ); + let command = format!("sh -c '{}'", inner.replace('\'', r"'\''")); let new_pane = self.client.split_sidebar_pane( &target.id, position == SidebarPosition::Left, diff --git a/packages/runtime-rs/tests/tmux_provider.rs b/packages/runtime-rs/tests/tmux_provider.rs index 5ae0294..6fd8c5d 100644 --- a/packages/runtime-rs/tests/tmux_provider.rs +++ b/packages/runtime-rs/tests/tmux_provider.rs @@ -316,7 +316,7 @@ fn tmux_provider_spawns_sidebar_against_edge_pane_and_titles_it() { assert_eq!( split_call.last().map(String::as_str), Some( - "OPENSESSIONS_SESSION_NAME=alpha OPENSESSIONS_WINDOW_ID=@1 REFOCUS_WINDOW=@1 exec \"${OPENSESSIONS_DIR:-.}\"//scripts/start.sh" + "sh -c 'OPENSESSIONS_SESSION_NAME=alpha OPENSESSIONS_WINDOW_ID=@1 REFOCUS_WINDOW=@1 exec \"${OPENSESSIONS_DIR:-.}\"//scripts/start.sh'" ) ); assert!( From f7ca09d03b84fdd25cf6ecdf9307834864b9db4d Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 15:28:24 +0100 Subject: [PATCH 11/15] fix(server): keep agent-watcher last_seen/last_seen_at maps consistent Skip Idle snapshots when refreshing last_seen_at so it only tracks the same non-Idle keys the dedup cache (last_seen) holds. Previously Idle keys lingered in last_seen_at without ever appearing in last_seen, violating the maps' shared-key invariant. Addresses inspect review findings 3, 4, 6, 7. Co-authored-by: Isaac --- apps/server-rs/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 9c5130d..4177208 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -1669,6 +1669,12 @@ async fn run_agent_watcher_loop( snapshots.len() )); for snapshot in &snapshots { + // Track liveness only for keys the dedup cache (`last_seen`) + // can hold — non-Idle — so the two maps stay consistent and + // Idle keys do not linger in `last_seen_at`. + if snapshot.status == AgentStatus::Idle { + continue; + } last_seen_at.insert(agent_watcher_key(snapshot), now); } for snapshot in snapshots { From 8665f0639fd3e112be837d4dd4a14be4b2157b09 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 15:28:24 +0100 Subject: [PATCH 12/15] fix(tmux): quote env-var values in sidebar spawn command Double-quote OPENSESSIONS_SESSION_NAME / WINDOW_ID / REFOCUS_WINDOW values inside the sh -c command so a session name with spaces (or other word-splitting chars) does not break the launcher. Single quotes are still escaped for the sh -c wrapper. Addresses inspect review findings 2, 5. Co-authored-by: Isaac --- packages/runtime-rs/src/tmux_provider.rs | 2 +- packages/runtime-rs/tests/tmux_provider.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-rs/src/tmux_provider.rs b/packages/runtime-rs/src/tmux_provider.rs index a2ccd4d..2a2c22c 100644 --- a/packages/runtime-rs/src/tmux_provider.rs +++ b/packages/runtime-rs/src/tmux_provider.rs @@ -659,7 +659,7 @@ impl MuxProvider for TmuxProvider { // Forcing `sh` keeps the launcher portable regardless of the user's // interactive shell. Single quotes in the session name are escaped. let inner = format!( - "OPENSESSIONS_SESSION_NAME={} OPENSESSIONS_WINDOW_ID={window_id} REFOCUS_WINDOW={window_id} exec \"${{OPENSESSIONS_DIR:-.}}\"/{scripts_dir}/start.sh", + "OPENSESSIONS_SESSION_NAME=\"{}\" OPENSESSIONS_WINDOW_ID=\"{window_id}\" REFOCUS_WINDOW=\"{window_id}\" exec \"${{OPENSESSIONS_DIR:-.}}\"/{scripts_dir}/start.sh", target.session_name, ); let command = format!("sh -c '{}'", inner.replace('\'', r"'\''")); diff --git a/packages/runtime-rs/tests/tmux_provider.rs b/packages/runtime-rs/tests/tmux_provider.rs index 6fd8c5d..5ecb215 100644 --- a/packages/runtime-rs/tests/tmux_provider.rs +++ b/packages/runtime-rs/tests/tmux_provider.rs @@ -316,7 +316,7 @@ fn tmux_provider_spawns_sidebar_against_edge_pane_and_titles_it() { assert_eq!( split_call.last().map(String::as_str), Some( - "sh -c 'OPENSESSIONS_SESSION_NAME=alpha OPENSESSIONS_WINDOW_ID=@1 REFOCUS_WINDOW=@1 exec \"${OPENSESSIONS_DIR:-.}\"//scripts/start.sh'" + "sh -c 'OPENSESSIONS_SESSION_NAME=\"alpha\" OPENSESSIONS_WINDOW_ID=\"@1\" REFOCUS_WINDOW=\"@1\" exec \"${OPENSESSIONS_DIR:-.}\"//scripts/start.sh'" ) ); assert!( From 5da80137dd7c829a6302c9e85126245815cde626 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 15:33:44 +0100 Subject: [PATCH 13/15] fix(server): move sidebar highlight to switched-to session on switch-index switch_visible_index switched the tmux session but never updated focused_session, so the sidebar highlight (which follows focused_session) stayed on the previously selected session after an Alt+digit switch. Set focused_session to the target, mirroring move_focus. Co-authored-by: Isaac --- apps/server-rs/src/lib.rs | 4 ++++ apps/server-rs/tests/protocol_shell.rs | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs index 4177208..266a562 100644 --- a/apps/server-rs/src/lib.rs +++ b/apps/server-rs/src/lib.rs @@ -1433,6 +1433,10 @@ impl ReadOnlyMuxStateSource { return; }; provider.switch_session(&name, client_tty); + // Move the sidebar highlight to the switched-to session, mirroring the + // move_focus path. Without this, Alt+digit switches the tmux session but + // the highlighted selection stays on the previously focused session. + *self.focused_session.lock().unwrap() = Some(name); } fn move_focus(&self, delta: i64, current_session: Option<&str>) -> Option { diff --git a/apps/server-rs/tests/protocol_shell.rs b/apps/server-rs/tests/protocol_shell.rs index 3c6a582..3de8551 100644 --- a/apps/server-rs/tests/protocol_shell.rs +++ b/apps/server-rs/tests/protocol_shell.rs @@ -551,11 +551,16 @@ async fn websocket_switch_index_switches_to_visible_session() { .await .expect("refresh command should send"); - let _ = timeout(Duration::from_secs(1), receiver.next()) + let state = timeout(Duration::from_secs(1), receiver.next()) .await .expect("refresh state should arrive before timeout") .expect("refresh state should arrive") .expect("refresh state should be valid"); + let state_text = state.as_text().expect("state should be text"); + assert!( + state_text.contains(r#""focusedSession":"worker""#), + "switch-index should move the sidebar highlight (focusedSession) to the switched-to session; got: {state_text}" + ); assert_eq!( *mux.switch_calls.lock().unwrap(), vec![("worker".to_string(), None)] From 4dce1c4764e7dfadb5b59361d7cba7c40c447ff1 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 16:36:09 +0100 Subject: [PATCH 14/15] fix(tmux): keep a lone sidebar that is the session's only window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kill_orphaned_sidebar_panes killed any sidebar whose window had no other panes. When you closed the last work pane in a window, the sidebar was left alone and got killed, leaving the window with zero panes — which destroys the window, and the session if it was the last one (with detach-on-destroy, the client then detaches and it looks like the session 'closed'). Now only reclaim a lone sidebar when its session has other windows; otherwise keep it so the session survives and focus lands on the sidebar. Co-authored-by: Isaac --- packages/runtime-rs/src/tmux_provider.rs | 24 ++++++++++++++++++-- packages/runtime-rs/tests/tmux_provider.rs | 26 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/runtime-rs/src/tmux_provider.rs b/packages/runtime-rs/src/tmux_provider.rs index 2a2c22c..2eb21dd 100644 --- a/packages/runtime-rs/src/tmux_provider.rs +++ b/packages/runtime-rs/src/tmux_provider.rs @@ -605,6 +605,8 @@ impl MuxProvider for TmuxProvider { let panes = self.client.list_panes(PaneScope::All); let mut window_pane_counts: HashMap = HashMap::new(); let mut sidebars_by_window: HashMap> = HashMap::new(); + let mut window_session: HashMap = HashMap::new(); + let mut windows_by_session: HashMap> = HashMap::new(); let mut seen_pane_ids = HashSet::new(); for pane in panes { @@ -614,6 +616,13 @@ impl MuxProvider for TmuxProvider { *window_pane_counts .entry(pane.window_id.clone()) .or_insert(0) += 1; + window_session + .entry(pane.window_id.clone()) + .or_insert_with(|| pane.session_name.clone()); + windows_by_session + .entry(pane.session_name.clone()) + .or_default() + .insert(pane.window_id.clone()); if pane.title == "opensessions-sidebar" { sidebars_by_window .entry(pane.window_id) @@ -624,8 +633,19 @@ impl MuxProvider for TmuxProvider { for (window_id, sidebars) in sidebars_by_window { if window_pane_counts.get(&window_id) == Some(&1) { - for pane_id in sidebars { - self.client.kill_pane(&pane_id); + // The window holds only the sidebar. Killing it leaves the + // window with zero panes, destroying the window — and the + // session too if this is its last window. Only reclaim it when + // the session has other windows; otherwise keep the sidebar so + // closing the last work pane doesn't close the whole session. + let session_has_other_windows = window_session + .get(&window_id) + .and_then(|session| windows_by_session.get(session)) + .is_some_and(|windows| windows.len() > 1); + if session_has_other_windows { + for pane_id in sidebars { + self.client.kill_pane(&pane_id); + } } continue; } diff --git a/packages/runtime-rs/tests/tmux_provider.rs b/packages/runtime-rs/tests/tmux_provider.rs index 5ecb215..c8d581d 100644 --- a/packages/runtime-rs/tests/tmux_provider.rs +++ b/packages/runtime-rs/tests/tmux_provider.rs @@ -248,7 +248,7 @@ fn tmux_provider_resolves_focuses_and_kills_agent_panes() { fn tmux_provider_kills_orphaned_and_duplicate_sidebar_panes() { let runner = Arc::new(RecordingRunner::new(HashMap::from([( "list-panes".to_string(), - "%1\talpha\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%1\talpha-2\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%2\tbeta\t@2\t0\t0\t1\t/dev/ttys2\t124\t/repo\tbash\tmain\t94\t24\t26\t119\n%3\tbeta\t@2\t0\t1\t0\t/dev/ttys3\t125\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%4\tbeta\t@2\t0\t2\t0\t/dev/ttys4\t126\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%5\t_os_stash\t@3\t0\t0\t1\t/dev/ttys5\t127\t/tmp\tzsh\topensessions-sidebar\t26\t24\t0\t25" + "%1\talpha\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%1\talpha-2\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%2\tbeta\t@2\t0\t0\t1\t/dev/ttys2\t124\t/repo\tbash\tmain\t94\t24\t26\t119\n%3\tbeta\t@2\t0\t1\t0\t/dev/ttys3\t125\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%4\tbeta\t@2\t0\t2\t0\t/dev/ttys4\t126\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%5\t_os_stash\t@3\t0\t0\t1\t/dev/ttys5\t127\t/tmp\tzsh\topensessions-sidebar\t26\t24\t0\t25\n%6\talpha\t@4\t0\t0\t1\t/dev/ttys6\t128\t/repo\tbash\tmain\t94\t24\t26\t119" .to_string(), )]))); let provider = TmuxProvider::new(runner.clone()); @@ -280,6 +280,30 @@ fn tmux_provider_kills_orphaned_and_duplicate_sidebar_panes() { ); } +#[test] +fn tmux_provider_keeps_lone_sidebar_when_it_is_the_sessions_only_window() { + // A window containing only the sidebar, in a session that has no other + // window, must NOT be killed: doing so would leave the window with zero + // panes, destroying the session (closing the last work pane should not + // close the whole session). + let runner = Arc::new(RecordingRunner::new(HashMap::from([( + "list-panes".to_string(), + "%9\tsolo\t@9\t0\t0\t1\t/dev/ttys9\t130\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25" + .to_string(), + )]))); + let provider = TmuxProvider::new(runner.clone()); + + provider.kill_orphaned_sidebar_panes(); + + let calls = runner.calls.lock().unwrap().clone(); + assert!( + !calls + .iter() + .any(|call| call == &vec!["kill-pane", "-t", "%9"]), + "a lone sidebar that is the session's only window must be kept (killing it would destroy the session)" + ); +} + #[test] fn tmux_provider_spawns_sidebar_against_edge_pane_and_titles_it() { let runner = Arc::new(RecordingRunner::new(HashMap::from([ From 9b63263903392a51f6ebcdf10c001bc8f80a5bd3 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Sun, 31 May 2026 10:42:04 +0100 Subject: [PATCH 15/15] fix(tmux): escape shell metacharacters in sidebar spawn env values The sidebar spawn command interpolates the tmux session name and window id into double-quoted `OPENSESSIONS_*` env assignments inside `sh -c '...'`. The outer single-quote escaping only guards the `sh -c` wrapper; inside the double quotes `$`, backtick, `"` and `\` stay live, so a session name like `x"; rm -rf ~ #` could break out of the quotes and inject commands. Add `sh_double_quote_escape()` and run the session name and window id through it before interpolation. Backslash is escaped first so the backslashes added for the other metacharacters are not doubled. Regression tests cover a name with `"`, `$` and backtick (must be escaped, breakout sequence must not appear) and a name with a single quote (must survive the outer `'\''` wrap). The existing benign-name test is unchanged, confirming normal session names still render identically. Co-authored-by: Isaac --- packages/runtime-rs/src/tmux_provider.rs | 28 +++++++- packages/runtime-rs/tests/tmux_provider.rs | 74 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/runtime-rs/src/tmux_provider.rs b/packages/runtime-rs/src/tmux_provider.rs index 2eb21dd..c9eb2c6 100644 --- a/packages/runtime-rs/src/tmux_provider.rs +++ b/packages/runtime-rs/src/tmux_provider.rs @@ -677,10 +677,20 @@ impl MuxProvider for TmuxProvider { // `default-command`/`default-shell`, which may be a non-POSIX shell // (e.g. fish) that cannot parse `FOO=bar exec` or `${VAR:-default}`. // Forcing `sh` keeps the launcher portable regardless of the user's - // interactive shell. Single quotes in the session name are escaped. + // interactive shell. + // + // Quoting is two-layered: the session name / window id sit inside + // double quotes (so `$`, backtick, `"` and `\` are still live to the + // inner `sh`), so escape those metacharacters first; then the whole + // `inner` is wrapped in single quotes for `sh -c`, with embedded single + // quotes escaped via the standard `'\''` dance. Without the inner + // escape a session name like `x"; rm -rf ~ #` would break out of the + // double quotes and inject commands. let inner = format!( - "OPENSESSIONS_SESSION_NAME=\"{}\" OPENSESSIONS_WINDOW_ID=\"{window_id}\" REFOCUS_WINDOW=\"{window_id}\" exec \"${{OPENSESSIONS_DIR:-.}}\"/{scripts_dir}/start.sh", - target.session_name, + "OPENSESSIONS_SESSION_NAME=\"{}\" OPENSESSIONS_WINDOW_ID=\"{}\" REFOCUS_WINDOW=\"{}\" exec \"${{OPENSESSIONS_DIR:-.}}\"/{scripts_dir}/start.sh", + sh_double_quote_escape(&target.session_name), + sh_double_quote_escape(window_id), + sh_double_quote_escape(window_id), ); let command = format!("sh -c '{}'", inner.replace('\'', r"'\''")); let new_pane = self.client.split_sidebar_pane( @@ -699,6 +709,18 @@ impl MuxProvider for TmuxProvider { } } +/// Escape the characters that stay special inside a POSIX double-quoted +/// string (`\`, `"`, `$`, backtick) so an untrusted value (e.g. a tmux session +/// name) can be interpolated into `FOO="..."` without breaking out of the +/// quotes or triggering command/parameter substitution. Backslash is escaped +/// first so the backslashes added for the others are not doubled. +fn sh_double_quote_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace('`', "\\`") +} + fn session_format() -> &'static str { "#{session_id}\t#{session_name}\t#{session_created}\t#{session_attached}\t#{session_windows}\t#{session_path}" } diff --git a/packages/runtime-rs/tests/tmux_provider.rs b/packages/runtime-rs/tests/tmux_provider.rs index c8d581d..5197d1a 100644 --- a/packages/runtime-rs/tests/tmux_provider.rs +++ b/packages/runtime-rs/tests/tmux_provider.rs @@ -350,6 +350,80 @@ fn tmux_provider_spawns_sidebar_against_edge_pane_and_titles_it() { ); } +#[test] +fn tmux_provider_escapes_shell_metacharacters_in_session_name() { + // spawn_sidebar takes the session name from the target pane's listing, so + // the untrusted value lives in the list-panes mock (field 2). A name + // carrying double quotes, `$` and a backtick must not break out of the + // `OPENSESSIONS_SESSION_NAME="..."` double quotes and inject commands. + let evil = r#"x"; touch /tmp/pwned; $(id)`whoami`"#; + let runner = Arc::new(RecordingRunner::new(HashMap::from([ + ( + "list-panes".to_string(), + format!("%1\t{evil}\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tbash\tmain\t80\t24\t0\t79"), + ), + ( + "split-window".to_string(), + format!("%9\t{evil}\t@1\t0\t1\t0\t/dev/ttys9\t999\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25"), + ), + ]))); + let provider = TmuxProvider::new(runner.clone()); + + provider.spawn_sidebar("ignored", "@1", 26, SidebarPosition::Left, "/scripts"); + + let calls = runner.calls.lock().unwrap().clone(); + let split_call = calls + .iter() + .find(|call| call.first().map(String::as_str) == Some("split-window")) + .expect("split-window should be called"); + let command = split_call.last().map(String::as_str).unwrap(); + + // Every metacharacter is backslash-escaped inside the double quotes. + assert!( + command.contains(r#"OPENSESSIONS_SESSION_NAME="x\"; touch /tmp/pwned; \$(id)\`whoami\`""#), + "session name not safely escaped: {command}" + ); + // The raw, unescaped breakout sequence must never appear. + assert!( + !command.contains(r#"="x"; touch"#), + "unescaped double quote broke out of the assignment: {command}" + ); +} + +#[test] +fn tmux_provider_escapes_single_quotes_in_session_name() { + // Single quotes are inert inside the inner double quotes but must survive + // the outer `sh -c '...'` wrap via the `'\''` dance. + let runner = Arc::new(RecordingRunner::new(HashMap::from([ + ( + "list-panes".to_string(), + "%1\to'brien\t@1\t0\t0\t1\t/dev/ttys1\t123\t/repo\tbash\tmain\t80\t24\t0\t79" + .to_string(), + ), + ( + "split-window".to_string(), + "%9\to'brien\t@1\t0\t1\t0\t/dev/ttys9\t999\t/repo\tzsh\topensessions-sidebar\t26\t24\t0\t25" + .to_string(), + ), + ]))); + let provider = TmuxProvider::new(runner.clone()); + + provider.spawn_sidebar("ignored", "@1", 26, SidebarPosition::Left, "/scripts"); + + let calls = runner.calls.lock().unwrap().clone(); + let command = calls + .iter() + .find(|call| call.first().map(String::as_str) == Some("split-window")) + .and_then(|call| call.last()) + .cloned() + .expect("split-window should be called"); + + assert_eq!( + command, + r#"sh -c 'OPENSESSIONS_SESSION_NAME="o'\''brien" OPENSESSIONS_WINDOW_ID="@1" REFOCUS_WINDOW="@1" exec "${OPENSESSIONS_DIR:-.}"//scripts/start.sh'"# + ); +} + #[test] fn std_command_runner_executes_tmux_binary_and_captures_output() { let runner = StdCommandRunner::new("printf");