Rust parity port: macOS auto theme-follow, 2 themes, agent-state bounds, singleton guard, fish spawn fix#45
Conversation
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
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
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
Config fields for the macOS auto theme-follow feature, persisted via update_map so save_config_to_home writes them. Co-authored-by: Isaac
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
…ty 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
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
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
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
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
There was a problem hiding this comment.
inspect review
Triage: 66 entities analyzed | 0 critical, 0 high, 34 medium, 32 low
Verdict: standard_review
Findings (7)
- [low] In
prune_terminal, thecleared_unseenvector is populated but the correspondingunseen_instancesentries are removed AFTER the session is potentially removed frominstances. If the session becomes empty and is removed, thecleared_unseenkeys are still removed fromunseen_instances, but this happens after we've lost the reference to which session they belonged to. This creates a logic error where we're building session-qualified keys (format!("{session}\0{key}")) but the session variable is still in scope from the outer loop, so it works correctly. However, the code structure is confusing and error-prone. - [low] In
spawn_sidebarfor tmux_provider, the shell command escaping for single quotes in session names is incomplete. The code doesinner.replace('\'', r"'\''")which escapes single quotes for the sh -c wrapper, but theOPENSESSIONS_SESSION_NAMEvalue itself is not quoted in the inner command string. If a session name contains spaces or special shell characters, the command will break. - [low] The
evict_stale_watcher_keysfunction has a potential logic error: it first retains entries inlast_seen_atbased on the time window, then retains entries inlast_seenthat exist inlast_seen_at. However, if a key exists inlast_seenbut not inlast_seen_at(which shouldn't happen in normal operation but could due to a bug), it would be silently removed without being within the eviction window. The maps should be kept in sync, but there's no assertion or guarantee of this invariant. - [low] In
run_agent_watcher_loop, the code populateslast_seen_atfor all snapshots at the start of each tick, but then only updateslast_seenfingerprints for non-Idle snapshots. This means Idle snapshots will have theirlast_seen_attimestamp updated (preventing eviction) but won't have a fingerprint inlast_seen. This creates an inconsistency wherelast_seen_atcan contain keys thatlast_seendoesn't have, violating the assumed invariant. - [low] In
spawn_sidebarin tmux_provider.rs, the command construction uses single quotes to wrap the shell command, and escapes single quotes in the session name with'\''. However, theinnerformat string contains double quotes around${OPENSESSIONS_DIR:-.}and the path, which will be inside single quotes. While this works, iftarget.session_namecontains a single quote, the escape sequence'\''will produce'\''literally in the shell command string, which is correct. But the real issue is thatwindow_idis interpolated directly without any escaping - ifwindow_idcontains single quotes or other shell metacharacters, the command could break or be exploited. - [low] In
evict_stale_watcher_keys, the function first retains entries inlast_seen_atbased on the time window, then retains entries inlast_seenthat exist inlast_seen_at. However, if a key exists inlast_seenbut not inlast_seen_at(which should not happen in normal operation but could happen if the maps get out of sync), it will be removed fromlast_seen. This is likely intentional for cleanup, but the function signature and documentation don't make it clear that it enforces consistency between the two maps. This is not a bug per se, but the logic assumes the maps are always in sync. - [low] In
run_agent_watcher_loop, the code updateslast_seen_atfor every snapshot in the scan, but this happens before checking if the snapshot status isIdle. This means evenIdlesnapshots update thelast_seen_attimestamp, preventing their keys from being evicted. However,Idlesnapshots are skipped in the fingerprint comparison logic (if snapshot.status == AgentStatus::Idle { continue; }), so their keys inlast_seenwill never be populated. This creates a memory leak:last_seen_atwill accumulate keys forIdlesnapshots that never get evicted because their timestamps keep getting refreshed, but they never appear inlast_seen.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
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
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
There was a problem hiding this comment.
inspect review
Triage: 66 entities analyzed | 0 critical, 0 high, 34 medium, 32 low
Verdict: standard_review
Findings (7)
- [low] In
prune_terminal, thecleared_unseenvector is populated but the correspondingunseen_instancesentries are only removed after the loop completes. However, if the session itself is removed (whenemptyis true), thecleared_unseenkeys will still reference that session and attempt to remove entries that may no longer be relevant. More critically, the logic collects keys to remove fromunseen_instancesbased on keys that were removed fromsession_instances, but this happens inside theif let Some(session_instances)block. Ifsession_instancesis None (session doesn't exist), thecleared_unseencleanup still runs but with an empty vector, which is fine. However, the real bug is thatcleared_unseenis scoped to each session iteration but the cleanup happens after potentially removing the session fromself.instances, creating a logical inconsistency. - [low] In
spawn_sidebarfor tmux_provider, the command wraps the entire spawn command insh -c '...'with single quotes, and attempts to escape single quotes in the session name withreplace('\''', r"'\''"'). However, the replacement pattern is incorrect: it's replacing the literal string'\''(backslash-single-quote-single-quote) instead of just'(single quote). This means single quotes in session names won't be properly escaped, leading to shell injection vulnerabilities or command failures. - [low] In
evict_stale_watcher_keys, the function retains entries inlast_seen_atwherenow.saturating_sub(*seen_at) < evict_ms, then retains entries inlast_seenonly if the key exists inlast_seen_at. However, ifnowis less than*seen_at(clock skew or time travel),saturating_subreturns 0, which is always< evict_ms, so the entry is kept. This is correct behavior for clock skew, but the function doesn't handle the case wherenowcould be u64::MAX and overflow scenarios properly. More importantly, there's no bug here on second inspection - saturating_sub handles underflow correctly. - [low] In
on_system_appearance_change, the function reads config from disk on every appearance change, which could fail (I/O error, malformed JSON). Ifload_config_from_homereturns a default config due to an error, the function will silently use default theme names (catppuccin-mocha/latte) instead of the user's configured themes. While this might be intentional fallback behavior, it means a temporary I/O error will override the user's theme choice without any indication. - [low] In the
spawn_sidebarcommand construction, the session name is wrapped in double quotes within the single-quoted sh command:OPENSESSIONS_SESSION_NAME="{}". If the session name contains a double quote character, it will break out of the double quotes and potentially cause command injection or syntax errors. The single-quote escaping only protects the outer sh -c layer, not the inner double-quoted session name. - [low] In
evict_stale_watcher_keys, the function retains entries inlast_seen_atbased on age, then retains entries inlast_seenonly if they exist inlast_seen_at. However, if a key exists inlast_seenbut not inlast_seen_at(which can happen if the maps get out of sync), it will be removed fromlast_seeneven though it was never checked for staleness. This asymmetry could cause premature eviction. - [low] In
run_agent_watcher_loop, the code trackslast_seen_atonly for non-Idle snapshots, but then callsevict_stale_watcher_keyswhich expects all keys inlast_seento have corresponding entries inlast_seen_at. If a key transitions from non-Idle to Idle and back, or if there's any inconsistency, keys could exist inlast_seenwithout entries inlast_seen_at, causing incorrect eviction.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
|
Addressed the Findings 2 & 5 — unquoted values in the spawn command (fixed, Findings 4 & 7 (and 3 & 6) — Finding 1 —
|
…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
There was a problem hiding this comment.
inspect review
Triage: 69 entities analyzed | 0 critical, 0 high, 35 medium, 34 low
Verdict: standard_review
Findings (6)
- [low] In
prune_terminal, thecleared_unseenvector is populated inside the filter closure but the closure only returns a boolean - the side effect of pushing tocleared_unseenhappens during iteration but the keys being pushed don't match the keys being removed. The code pushesformat!("{session}\0{key}")for ALL keys that pass the filter, but should only push for keys that are actually being removed (i.e., keys that will be in thekeysvector after collection). - [low] In
spawn_sidebarin tmux_provider.rs, the command wrapping logic has a critical shell escaping bug. The code escapes single quotes intarget.session_namewithreplace('\'', r"'\'''"), but this produces incorrect shell syntax. The correct POSIX escape for a single quote inside single-quoted strings is'\''(close quote, escaped quote, open quote), but the code produces'\'''which will fail to parse. - [low] In
evict_stale_watcher_keys, there's a logic error in the retention order. The function first retains entries inlast_seen_atbased on age, then retains entries inlast_seenbased on whether their key exists inlast_seen_at. However, if a key exists inlast_seenbut not inlast_seen_at(which can happen if the maps get out of sync), it will be removed fromlast_seeneven though it was never checked for staleness. The function should ensure both maps are consistent before pruning. - [low] In
run_agent_watcher_loop, the code updateslast_seen_atfor all non-Idle snapshots before the deduplication check inlast_seen. This means that if a snapshot is filtered out by the dedup logic (fingerprint unchanged), its timestamp inlast_seen_atis still updated, preventing it from ever being evicted even if it stops appearing in scans. The timestamp update should only happen when the snapshot actually passes dedup and gets reported. - [low] In
spawn_sidebarfor tmux_provider, the command wrapping logic has a critical shell escaping bug. The code escapes single quotes ininnerwithreplace('\'', r"'\'''"), butinnercontains the session name which is interpolated into a double-quoted string. If the session name contains double quotes or other shell metacharacters, they will not be escaped, leading to command injection. The session name should be shell-escaped before being placed in the double-quoted context. - [low] In
evict_stale_watcher_keys, the function retains entries inlast_seen_atwherenow.saturating_sub(*seen_at) < evict_ms, but then retains entries inlast_seenonly if the key exists inlast_seen_at. However, the logic for updatinglast_seen_atinrun_agent_watcher_looponly inserts keys for non-Idle snapshots. This means if a snapshot transitions from non-Idle to Idle, its key will never be added tolast_seen_at, but it may still exist inlast_seen(if it was added before the Idle transition), causing an asymmetry wherelast_seencan contain keys not inlast_seen_at.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
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
There was a problem hiding this comment.
inspect review
Triage: 72 entities analyzed | 0 critical, 0 high, 37 medium, 35 low
Verdict: standard_review
Findings (7)
- [low] In
prune_terminal, the variablecleared_unseenis populated but the corresponding entries are removed fromself.unseen_instancesAFTER the mutable borrow ofself.instancesends. However,cleared_unseenis built from keys that match the filter condition, but those keys are removed fromsession_instancesinside the mutable borrow. This means if a key is removed fromsession_instances, it gets added tocleared_unseen, but the logic doesn't guarantee that all keys incleared_unseenshould be removed fromunseen_instances- only those that were actually pruned should be cleared from unseen. - [low] In
evict_stale_watcher_keys, the function retains entries inlast_seen_atwherenow.saturating_sub(*seen_at) < evict_ms, then retains entries inlast_seenthat exist inlast_seen_at. However, inrun_agent_watcher_loop,last_seen_atis updated for ALL snapshots (including those with status Idle), butlast_seenonly tracks non-Idle snapshots. This asymmetry meanslast_seen_atcan contain keys thatlast_seennever had, causing the eviction logic to be ineffective for those keys. - [low] In
spawn_sidebarfor tmux_provider.rs, the command wraps the entire spawn command insh -c '...'with single quotes, and escapes single quotes in the session name with'\''. However, the session name is only used in one place (OPENSESSIONS_SESSION_NAME="{}"), but the window_id appears in multiple places and is NOT escaped. If a window_id contains a single quote, it will break the shell command syntax. - [low] In
kill_orphaned_sidebar_panes, the code buildswindow_sessionandwindows_by_sessionmaps by iterating through panes, but it only inserts into these maps when processing panes. If a window has only a sidebar pane, the logic checkssession_has_other_windowsby looking up the session inwindows_by_session. However, the conditionwindows.len() > 1checks if the session has more than one window, but this count includes the current window. The correct check should be whether there are OTHER windows besides the current one. - [low] In
watch_mac_system_appearance(macOS version), thecheckclosure is cloned and moved into two separate threads (file watcher and safety poll). Both threads callcheck()which lockslastmutex. If both threads fire simultaneously, there's a potential race condition where both read the samelastvalue, both see it's different from current mode, both update it, and both callon_change- resulting in duplicate callbacks for the same appearance change. - [low] In
prune_terminal, the variablecleared_unseenis populated inside the filter closure but the closure only returns a boolean - the side effect of pushing tocleared_unseenhappens during filtering, not during the subsequent removal loop. This meanscleared_unseenwill contain keys that were candidates for removal (matched the filter), not necessarily the keys that were actually removed. If a key exists in the filter butsession_instances.remove(&key)fails for any reason, it will still be added tocleared_unseenand incorrectly removed fromunseen_instances. - [low] In
spawn_sidebar, the command wrapping logic escapes single quotes ininnerwithreplace('\'', r"'\''")), butinnercontains the session name which may have double quotes. The session name is wrapped in double quotes in the inner command but not escaped, allowing command injection if a session name contains a double quote followed by shell metacharacters.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
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
There was a problem hiding this comment.
inspect review
Triage: 75 entities analyzed | 0 critical, 0 high, 37 medium, 38 low
Verdict: standard_review
Findings (5)
- [low] In
prune_terminal, the variablecleared_unseenis populated but the corresponding entries are removed fromself.unseen_instancesAFTER the session is potentially removed fromself.instances. Ifemptyis true and the session is removed, the loop tries to remove unseen entries for a session that no longer exists in the tracker, but more critically, the unseen_instances cleanup happens outside theif let Some(session_instances)block, so it will attempt to remove entries even whensession_instanceswas None. - [low] In
evict_stale_watcher_keys, the function retains entries inlast_seen_atwhere the age is less thanevict_ms, then retains entries inlast_seenthat exist inlast_seen_at. However, thelast_seen_atmap is updated in the agent watcher loop only for non-Idle snapshots, butlast_seencan contain entries for any status that was previously non-Idle. If a key transitions from Running to Idle, it will remain inlast_seenbut stop getting updates inlast_seen_at, causing it to eventually be evicted even though it might still be relevant. - [low] In
spawn_sidebar, thesh_double_quote_escapefunction is called on session_name and window_id, but the result is placed inside double quotes in the inner command string. However, the entire inner string is then wrapped in single quotes forsh -c '...', and single quotes within that are escaped using'\''. This means if session_name or window_id contain single quotes, they will be escaped in the outer layer but NOT in the inner double-quoted layer, potentially breaking the command. - [low] In
kill_orphaned_sidebar_panes, the code checks if a session has other windows by looking upwindow_session.get(&window_id)and then checking ifwindows_by_sessionfor that session has length > 1. However, if the window_id is not inwindow_session(which should not happen but is not impossible), theand_thenwill return None andis_some_andwill return false, causing the sidebar to be kept even when it should be killed. - [low] In
prune_terminal, the logic for determining whether to prune an instance checks if it's unseen and applies a longer timeout (TERMINAL_HARD_PRUNE_MS), otherwise uses TERMINAL_PRUNE_MS. However, thecleared_unseenvector is populated for ALL pruned keys, not just the ones that were actually unseen. This means seen instances that are pruned will also have their (non-existent) unseen entries attempted to be removed, which is harmless but indicates a logic error.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
Ports a set of features and fixes onto the Rust/ratatui runtime (v0.2.0-alpha.6). All changes are cross-platform (the macOS-only feature is
cfg(target_os = "macos")-gated with no-op fallbacks).cargo test --workspaceis green (16 new tests); no unrelated reformatting (the repo isn't rustfmt-enforced, so I matched the existing style).Bug fix (affects all fish/csh users)
fix(tmux): wrap the sidebar spawn command insh -c.spawn_sidebaremitted a POSIX command (FOO=bar exec …; "${VAR:-default}") that tmux runs via the user'sdefault-command/default-shell. Under a non-POSIX shell (e.g. fish) it fails to parse and the sidebar pane dies with status 127 beforestart.shruns. Wrapping insh -c '…'makes it shell-agnostic.Feature: follow macOS system Appearance (auto dark/light theme)
feat(config): addautoThemeFollowsSystem,darkTheme,lightTheme.feat(runtime): asystem_thememodule — appearance read (defaults read -g AppleInterfaceStyle), a pure mode→theme mapping, and a push-based watcher (notifyon~/Library/Preferences/.GlobalPreferences.plist+ a 60s safety poll). macOS-gated; no-op elsewhere.feat(server): auto-switch the theme on appearance change (re-reads config each time so manual overrides win), and persist a manualset-themeto the per-appearance slot so dark and light remember independently. Opt-in (off by default; tests stay hermetic).Feature: two new themes
feat(themes):tokyo-night-stormandtango-adapted(light).Robustness: bound long-running state
feat(tracker): hard-prune unseen terminal agents after 15 min (previously unseen terminals were never pruned, so they accumulated indefinitely).feat(server): evict agent-watcher dedup-cache entries whose key hasn't appeared in a scan for 15 min (thelast_seenmap grew unbounded on a long-running server).feat(server): clean singleton guard — on a port conflict, probe the pid file and exit cleanly with "another server is already running (pid N)" instead of a bareAddrInUse; a stale (dead) pid file does not block startup.Testing
system_themeprimitives, the singleton helpers, and thesh -cspawn wrapper.cargo test --workspace: all green.This pull request and its description were written by Isaac.