From 19ec1d934ee25ad1cf00c85a12596e07c77c2ae5 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Thu, 23 Apr 2026 21:41:41 +0100 Subject: [PATCH 01/10] feat(themes): follow macOS Appearance and auto-switch dark/light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in background poller that reads the macOS Appearance setting (`defaults read -g AppleInterfaceStyle`) every few seconds and flips the active theme between a configured dark and light pair when the user toggles System Settings → Appearance. Config: autoThemeFollowsSystem: true darkTheme: "catppuccin-mocha" (default) lightTheme: "catppuccin-latte" (default) macOS-gated — no-op on Linux/Windows. Uses the same broadcast path as the manual theme picker so every connected sidebar re-renders in sync. Poller is a 3s interval cleaned up on shutdown. Extracted pure mapping and side-effectful read into `system-theme.ts` for testability. Covered by 5 new tests. --- packages/runtime/src/config.ts | 6 +++ packages/runtime/src/server/index.ts | 27 ++++++++++++ packages/runtime/src/system-theme.ts | 50 ++++++++++++++++++++++ packages/runtime/test/config.test.ts | 28 ++++++++++++ packages/runtime/test/system-theme.test.ts | 39 +++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 packages/runtime/src/system-theme.ts create mode 100644 packages/runtime/test/system-theme.test.ts diff --git a/packages/runtime/src/config.ts b/packages/runtime/src/config.ts index a0552fa..a2eba7d 100644 --- a/packages/runtime/src/config.ts +++ b/packages/runtime/src/config.ts @@ -25,6 +25,12 @@ export interface OpensessionsConfig { detailPanelHeights?: Record; /** Default session filter: "all" (default), "active" (any agent), "running" (running agents only) */ sessionFilter?: SessionFilterMode; + /** macOS only: automatically follow the system Appearance setting and switch themes */ + autoThemeFollowsSystem?: boolean; + /** Theme to use when the macOS system Appearance is Dark (default: "catppuccin-mocha") */ + darkTheme?: string; + /** Theme to use when the macOS system Appearance is Light (default: "catppuccin-latte") */ + lightTheme?: string; } const DEFAULTS: OpensessionsConfig = { diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 7718c8d..6274851 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -24,6 +24,7 @@ import { } from "./sidebar-coordinator"; import { loadConfig, saveConfig } from "../config"; import type { SessionFilterMode } from "../config"; +import { readMacSystemAppearance, themeForSystemMode } from "../system-theme"; import { clampSidebarWidth, } from "./sidebar-width-sync"; @@ -304,6 +305,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa const config = loadConfig(); let currentTheme: string | undefined = typeof config.theme === "string" ? config.theme : undefined; let currentFilter: SessionFilterMode | undefined = config.sessionFilter; + let systemThemePollTimer: ReturnType | null = null; const initialSidebarWidth = clampSidebarWidth(config.sidebarWidth ?? 26); let sidebarPosition: "left" | "right" = config.sidebarPosition ?? "left"; const sidebarCoordinator = createSidebarCoordinator({ width: initialSidebarWidth }); @@ -2165,6 +2167,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa clearProgrammaticAdjustmentTimer(); if (portPollTimer) clearInterval(portPollTimer); if (paneScanTimer) clearInterval(paneScanTimer); + if (systemThemePollTimer) clearInterval(systemThemePollTimer); for (const timer of pendingHighlightResets.values()) clearTimeout(timer); pendingHighlightResets.clear(); for (const watcher of gitHeadWatchers.values()) watcher.close(); @@ -2606,6 +2609,30 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa startIdleTimerIfNeeded("server booted without clients"); + // --- macOS system-appearance follower ----------------------------------- + // When `autoThemeFollowsSystem` is set, poll the macOS Appearance setting + // every few seconds and flip between the configured dark/light themes. + // macOS does not expose a CLI change-notification; polling is cheap. + if (config.autoThemeFollowsSystem && process.platform === "darwin") { + const darkTheme = config.darkTheme ?? "catppuccin-mocha"; + const lightTheme = config.lightTheme ?? "catppuccin-latte"; + + async function syncSystemTheme() { + const mode = await readMacSystemAppearance(); + const desired = themeForSystemMode(mode, darkTheme, lightTheme); + if (desired === currentTheme) return; + log("system-theme", "switching", { mode, from: currentTheme, to: desired }); + currentTheme = desired; + saveConfig({ theme: desired }); + broadcastState(); + } + + void syncSystemTheme(); + systemThemePollTimer = setInterval(() => { void syncSystemTheme(); }, 3000); + log("system-theme", "poller started", { darkTheme, lightTheme }); + } + // ------------------------------------------------------------------------ + process.on("SIGINT", () => { cleanup(); process.exit(0); }); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); diff --git a/packages/runtime/src/system-theme.ts b/packages/runtime/src/system-theme.ts new file mode 100644 index 0000000..af3754c --- /dev/null +++ b/packages/runtime/src/system-theme.ts @@ -0,0 +1,50 @@ +/** + * macOS system-appearance helpers. + * + * On macOS, the global "Appearance" preference (System Settings → Appearance) + * flips between Light and Dark. We expose two helpers: + * - `readMacSystemAppearance()` reads the current setting via `defaults`. + * - `themeForSystemMode()` maps a mode + configured theme names to the + * theme the server should apply. + * + * The pair is enough for a simple polling loop in the server. macOS does not + * expose a CLI change-notification, so polling every few seconds is the + * pragmatic approach; the calls are cheap (one `defaults` subprocess). + */ + +export type SystemAppearanceMode = "dark" | "light"; + +/** + * Read the current macOS Appearance setting. + * + * `defaults read -g AppleInterfaceStyle` returns "Dark" when Dark mode is + * active and exits non-zero with an empty stdout when Light is active + * (the key is simply absent). We map both absent/unreadable cases to "light". + * + * Safe to call on non-macOS platforms — returns "light" and does not throw. + */ +export async function readMacSystemAppearance(): Promise { + if (process.platform !== "darwin") return "light"; + try { + const proc = Bun.spawn(["defaults", "read", "-g", "AppleInterfaceStyle"], { + stdout: "pipe", + stderr: "pipe", + }); + const out = (await new Response(proc.stdout).text()).trim(); + return out === "Dark" ? "dark" : "light"; + } catch { + return "light"; + } +} + +/** + * Map a detected system appearance to the theme name the server should set. + * Pure — trivially testable. + */ +export function themeForSystemMode( + mode: SystemAppearanceMode, + darkTheme: string, + lightTheme: string, +): string { + return mode === "dark" ? darkTheme : lightTheme; +} diff --git a/packages/runtime/test/config.test.ts b/packages/runtime/test/config.test.ts index a09d1f8..65d0c0b 100644 --- a/packages/runtime/test/config.test.ts +++ b/packages/runtime/test/config.test.ts @@ -98,6 +98,34 @@ describe("Config", () => { const { rmSync } = require("fs"); rmSync(tmpDir, { recursive: true, force: true }); }); + + test("loadConfig round-trips auto-theme fields", async () => { + const tmpDir = `/tmp/opensessions-test-${Date.now()}`; + const configDir = join(tmpDir, ".config", "opensessions"); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + autoThemeFollowsSystem: true, + darkTheme: "tokyo-night", + lightTheme: "catppuccin-latte", + }), + ); + + const config = loadConfig(tmpDir); + expect(config.autoThemeFollowsSystem).toBe(true); + expect(config.darkTheme).toBe("tokyo-night"); + expect(config.lightTheme).toBe("catppuccin-latte"); + + const { rmSync } = require("fs"); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("loadConfig leaves auto-theme fields unset when absent", () => { + const config = loadConfig("/tmp/nonexistent-dir-" + Date.now()); + expect(config.autoThemeFollowsSystem).toBeUndefined(); + expect(config.darkTheme).toBeUndefined(); + expect(config.lightTheme).toBeUndefined(); + }); }); describe("Themes", () => { diff --git a/packages/runtime/test/system-theme.test.ts b/packages/runtime/test/system-theme.test.ts new file mode 100644 index 0000000..64060d4 --- /dev/null +++ b/packages/runtime/test/system-theme.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from "bun:test"; + +import { readMacSystemAppearance, themeForSystemMode } from "../src/system-theme"; + +describe("themeForSystemMode", () => { + test("dark mode → dark theme", () => { + expect(themeForSystemMode("dark", "catppuccin-mocha", "catppuccin-latte")) + .toBe("catppuccin-mocha"); + }); + + test("light mode → light theme", () => { + expect(themeForSystemMode("light", "catppuccin-mocha", "catppuccin-latte")) + .toBe("catppuccin-latte"); + }); + + test("respects custom theme names", () => { + expect(themeForSystemMode("dark", "tokyo-night", "github-light")).toBe("tokyo-night"); + expect(themeForSystemMode("light", "tokyo-night", "github-light")).toBe("github-light"); + }); +}); + +describe("readMacSystemAppearance", () => { + test("returns 'light' on non-darwin without throwing", async () => { + // The helper short-circuits on non-darwin platforms, so this is a + // portable sanity check. On darwin it will read the real setting. + const result = await readMacSystemAppearance(); + expect(["dark", "light"]).toContain(result); + }); + + test("on non-darwin platforms returns 'light' deterministically", async () => { + const original = process.platform; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + try { + expect(await readMacSystemAppearance()).toBe("light"); + } finally { + Object.defineProperty(process, "platform", { value: original, configurable: true }); + } + }); +}); From 20b32fa9ff95d278ba030c1eb862ecfd4003300f Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Tue, 28 Apr 2026 12:11:28 +0200 Subject: [PATCH 02/10] fix(themes): persist manual override per system appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `autoThemeFollowsSystem` is on and the user manually picks a theme via `set-theme`, route the persistence to `darkTheme` or `lightTheme` based on the current macOS appearance instead of `theme`. Previously the choice was written to `theme`, which the poll loop ignored, so the manual override was silently overwritten on the next 3s tick. Also re-load `darkTheme` / `lightTheme` from disk inside the poll cycle so an override made via `set-theme` is picked up immediately on the next poll. Drops the `saveConfig({ theme: desired })` write inside the poll loop — that was clobbering the user's static-mode `theme` field whenever auto-follow ran. Co-authored-by: Isaac --- packages/runtime/src/server/index.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 6274851..d15cfdc 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -306,6 +306,11 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa let currentTheme: string | undefined = typeof config.theme === "string" ? config.theme : undefined; let currentFilter: SessionFilterMode | undefined = config.sessionFilter; let systemThemePollTimer: ReturnType | null = null; + // Tracks the most recently observed macOS appearance while auto-follow is active. + // Used by the `set-theme` handler so a manual override is persisted to the + // appearance-specific slot, not to `theme` (which would be clobbered next poll). + let autoThemeFollowing = false; + let currentSystemMode: "dark" | "light" | undefined; const initialSidebarWidth = clampSidebarWidth(config.sidebarWidth ?? 26); let sidebarPosition: "left" | "right" = config.sidebarPosition ?? "left"; const sidebarCoordinator = createSidebarCoordinator({ width: initialSidebarWidth }); @@ -2061,7 +2066,16 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa break; case "set-theme": currentTheme = cmd.theme; - saveConfig({ theme: cmd.theme }); + if (autoThemeFollowing) { + // When auto-follow is active, persist the manual choice to the + // appearance-specific slot so the next poll cycle does not silently + // overwrite it. Falls back to `theme` if mode hasn't been read yet. + if (currentSystemMode === "dark") saveConfig({ darkTheme: cmd.theme }); + else if (currentSystemMode === "light") saveConfig({ lightTheme: cmd.theme }); + else saveConfig({ theme: cmd.theme }); + } else { + saveConfig({ theme: cmd.theme }); + } broadcastState(); break; case "set-filter": @@ -2614,16 +2628,23 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa // every few seconds and flip between the configured dark/light themes. // macOS does not expose a CLI change-notification; polling is cheap. if (config.autoThemeFollowsSystem && process.platform === "darwin") { + autoThemeFollowing = true; const darkTheme = config.darkTheme ?? "catppuccin-mocha"; const lightTheme = config.lightTheme ?? "catppuccin-latte"; async function syncSystemTheme() { const mode = await readMacSystemAppearance(); - const desired = themeForSystemMode(mode, darkTheme, lightTheme); + currentSystemMode = mode; + // Re-read the per-mode theme each cycle so a manual override via the + // `set-theme` handler (which writes to `darkTheme` / `lightTheme`) is + // picked up on the next poll instead of being silently overwritten. + const fresh = loadConfig(); + const dark = fresh.darkTheme ?? darkTheme; + const light = fresh.lightTheme ?? lightTheme; + const desired = themeForSystemMode(mode, dark, light); if (desired === currentTheme) return; log("system-theme", "switching", { mode, from: currentTheme, to: desired }); currentTheme = desired; - saveConfig({ theme: desired }); broadcastState(); } From a733f286a523e069696259aa549a3f6b24eaf72c Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Mon, 4 May 2026 14:50:38 +0100 Subject: [PATCH 03/10] perf(runtime): push-based theme watch + dedup redundant broadcasts Two server hot-path optimizations targeting steady-state CPU and WS fan-out: 1. Replace the 3s `defaults read` polling loop with a kqueue-based file watch on `~/Library/Preferences/.GlobalPreferences.plist`. macOS rewrites that plist on any global-preference change, so we let the kernel push us the event and re-read appearance only when the value differs. A 60s safety poll covers the atomic-rename case where kqueue loses the inode. Removes ~28,800 subprocess spawns per server per day. 2. Hash the serialized state in `broadcastStateImmediate()` and skip the `server.publish("sidebar", msg)` when the payload is byte-identical to the previous send. Many call sites trigger broadcastState() but most do not actually change observable state (theme polls, focus moves to the same session, agent updates with identical metadata). Wire protocol is unchanged; new clients still receive `lastState` directly via the WS `open` handler. Tests: 400/400 passing (was 397; +3 covering the watcher's no-op fallback, idempotent stop, and darwin initial-fire). --- packages/runtime/src/server/index.ts | 45 ++++++++----- packages/runtime/src/system-theme.ts | 75 ++++++++++++++++++++-- packages/runtime/test/system-theme.test.ts | 44 ++++++++++++- 3 files changed, 143 insertions(+), 21 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index d15cfdc..23b727d 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -24,7 +24,12 @@ import { } from "./sidebar-coordinator"; import { loadConfig, saveConfig } from "../config"; import type { SessionFilterMode } from "../config"; -import { readMacSystemAppearance, themeForSystemMode } from "../system-theme"; +import { + readMacSystemAppearance, + themeForSystemMode, + watchMacSystemAppearance, + type SystemAppearanceWatcher, +} from "../system-theme"; import { clampSidebarWidth, } from "./sidebar-width-sync"; @@ -305,7 +310,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa const config = loadConfig(); let currentTheme: string | undefined = typeof config.theme === "string" ? config.theme : undefined; let currentFilter: SessionFilterMode | undefined = config.sessionFilter; - let systemThemePollTimer: ReturnType | null = null; + let systemThemeWatcher: SystemAppearanceWatcher | null = null; // Tracks the most recently observed macOS appearance while auto-follow is active. // Used by the `set-theme` handler so a manual override is persisted to the // appearance-specific slot, not to `theme` (which would be clobbered next poll). @@ -734,6 +739,13 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa } let broadcastPending = false; + // Hash of the last bytes published to "sidebar". Many call sites trigger + // broadcastState() but most do not actually change observable state (e.g. + // theme polls, focus moves that resolve to the same session, agent + // updates that produce identical metadata). Hashing the serialized + // payload and skipping the publish when unchanged kills redundant fan-out + // to all WS clients without changing the wire protocol. + let lastBroadcastHash: bigint | null = null; function broadcastState() { if (broadcastPending) return; @@ -751,6 +763,9 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa lastState = computeState(); syncGitWatchers(lastState.sessions, broadcastState); const msg = JSON.stringify(lastState); + const hash = Bun.hash(msg); + if (hash === lastBroadcastHash) return; + lastBroadcastHash = hash; server.publish("sidebar", msg); } @@ -2181,7 +2196,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa clearProgrammaticAdjustmentTimer(); if (portPollTimer) clearInterval(portPollTimer); if (paneScanTimer) clearInterval(paneScanTimer); - if (systemThemePollTimer) clearInterval(systemThemePollTimer); + if (systemThemeWatcher) systemThemeWatcher.stop(); for (const timer of pendingHighlightResets.values()) clearTimeout(timer); pendingHighlightResets.clear(); for (const watcher of gitHeadWatchers.values()) watcher.close(); @@ -2624,33 +2639,33 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa startIdleTimerIfNeeded("server booted without clients"); // --- macOS system-appearance follower ----------------------------------- - // When `autoThemeFollowsSystem` is set, poll the macOS Appearance setting - // every few seconds and flip between the configured dark/light themes. - // macOS does not expose a CLI change-notification; polling is cheap. + // When `autoThemeFollowsSystem` is set, watch the macOS Appearance plist + // and flip between the configured dark/light themes on change. Push-based + // (kqueue) — replaces the previous 3-second polling loop that spawned a + // `defaults` subprocess on every tick. if (config.autoThemeFollowsSystem && process.platform === "darwin") { autoThemeFollowing = true; const darkTheme = config.darkTheme ?? "catppuccin-mocha"; const lightTheme = config.lightTheme ?? "catppuccin-latte"; - async function syncSystemTheme() { - const mode = await readMacSystemAppearance(); - currentSystemMode = mode; + async function syncSystemTheme(mode?: "dark" | "light") { + const observed = mode ?? (await readMacSystemAppearance()); + currentSystemMode = observed; // Re-read the per-mode theme each cycle so a manual override via the // `set-theme` handler (which writes to `darkTheme` / `lightTheme`) is - // picked up on the next poll instead of being silently overwritten. + // honoured on the next event instead of being silently overwritten. const fresh = loadConfig(); const dark = fresh.darkTheme ?? darkTheme; const light = fresh.lightTheme ?? lightTheme; - const desired = themeForSystemMode(mode, dark, light); + const desired = themeForSystemMode(observed, dark, light); if (desired === currentTheme) return; - log("system-theme", "switching", { mode, from: currentTheme, to: desired }); + log("system-theme", "switching", { mode: observed, from: currentTheme, to: desired }); currentTheme = desired; broadcastState(); } - void syncSystemTheme(); - systemThemePollTimer = setInterval(() => { void syncSystemTheme(); }, 3000); - log("system-theme", "poller started", { darkTheme, lightTheme }); + systemThemeWatcher = watchMacSystemAppearance((mode) => { void syncSystemTheme(mode); }); + log("system-theme", "watcher started", { darkTheme, lightTheme }); } // ------------------------------------------------------------------------ diff --git a/packages/runtime/src/system-theme.ts b/packages/runtime/src/system-theme.ts index af3754c..c893a33 100644 --- a/packages/runtime/src/system-theme.ts +++ b/packages/runtime/src/system-theme.ts @@ -2,16 +2,19 @@ * macOS system-appearance helpers. * * On macOS, the global "Appearance" preference (System Settings → Appearance) - * flips between Light and Dark. We expose two helpers: + * flips between Light and Dark. We expose three helpers: * - `readMacSystemAppearance()` reads the current setting via `defaults`. * - `themeForSystemMode()` maps a mode + configured theme names to the * theme the server should apply. - * - * The pair is enough for a simple polling loop in the server. macOS does not - * expose a CLI change-notification, so polling every few seconds is the - * pragmatic approach; the calls are cheap (one `defaults` subprocess). + * - `watchMacSystemAppearance()` invokes a callback on every detected + * appearance change. Push-based via kqueue file watch on the underlying + * plist; falls back to a slow safety poll for atomic-rename cases. */ +import { watch } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + export type SystemAppearanceMode = "dark" | "light"; /** @@ -48,3 +51,65 @@ export function themeForSystemMode( ): string { return mode === "dark" ? darkTheme : lightTheme; } + +export interface SystemAppearanceWatcher { + stop(): void; +} + +/** + * Watch the macOS Appearance setting and fire `onChange` when it flips. + * + * macOS rewrites `~/Library/Preferences/.GlobalPreferences.plist` whenever + * any global preference (including AppleInterfaceStyle) changes. We watch + * that file with kqueue (zero-overhead push) and re-read appearance on + * every event. Most events are unrelated to appearance (e.g. other prefs + * being written) so we suppress the callback unless the *value* actually + * changed. + * + * A 60s safety poll covers the rare case where the plist is replaced via + * atomic rename — kqueue loses the inode and the watcher goes silent. + * + * On non-darwin platforms returns a no-op watcher. + */ +export function watchMacSystemAppearance( + onChange: (mode: SystemAppearanceMode) => void | Promise, + opts?: { safetyPollMs?: number }, +): SystemAppearanceWatcher { + if (process.platform !== "darwin") { + return { stop() {} }; + } + + const plistPath = join(homedir(), "Library", "Preferences", ".GlobalPreferences.plist"); + let lastMode: SystemAppearanceMode | null = null; + let stopped = false; + + async function check() { + if (stopped) return; + const mode = await readMacSystemAppearance(); + if (mode !== lastMode) { + lastMode = mode; + await onChange(mode); + } + } + + let watcher: ReturnType | null = null; + try { + watcher = watch(plistPath, () => { void check(); }); + } catch { + // fall through — safety poll alone keeps us correct + } + + const safetyMs = opts?.safetyPollMs ?? 60_000; + const safetyTimer = setInterval(() => { void check(); }, safetyMs); + + // Initial read so the consumer learns the starting mode without waiting. + void check(); + + return { + stop() { + stopped = true; + try { watcher?.close(); } catch {} + clearInterval(safetyTimer); + }, + }; +} diff --git a/packages/runtime/test/system-theme.test.ts b/packages/runtime/test/system-theme.test.ts index 64060d4..126ef62 100644 --- a/packages/runtime/test/system-theme.test.ts +++ b/packages/runtime/test/system-theme.test.ts @@ -1,6 +1,10 @@ import { describe, test, expect } from "bun:test"; -import { readMacSystemAppearance, themeForSystemMode } from "../src/system-theme"; +import { + readMacSystemAppearance, + themeForSystemMode, + watchMacSystemAppearance, +} from "../src/system-theme"; describe("themeForSystemMode", () => { test("dark mode → dark theme", () => { @@ -37,3 +41,41 @@ describe("readMacSystemAppearance", () => { } }); }); + +describe("watchMacSystemAppearance", () => { + test("returns a no-op watcher on non-darwin", () => { + const original = process.platform; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + try { + let calls = 0; + const w = watchMacSystemAppearance(() => { calls++; }); + expect(typeof w.stop).toBe("function"); + w.stop(); + expect(calls).toBe(0); + } finally { + Object.defineProperty(process, "platform", { value: original, configurable: true }); + } + }); + + test("stop() is idempotent on non-darwin", () => { + const original = process.platform; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + try { + const w = watchMacSystemAppearance(() => {}); + w.stop(); + w.stop(); + } finally { + Object.defineProperty(process, "platform", { value: original, configurable: true }); + } + }); + + test("on darwin, fires callback with the initial mode", async () => { + if (process.platform !== "darwin") return; + let received: "dark" | "light" | null = null; + const w = watchMacSystemAppearance((mode) => { received = mode; }, { safetyPollMs: 60_000 }); + // Initial check is queued via void check() — give it a tick to land. + await new Promise((r) => setTimeout(r, 100)); + w.stop(); + expect(received === "dark" || received === "light").toBe(true); + }); +}); From 540dee69fab7e69e5ed401cd3f0026ce6071261a Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 15:59:49 +0100 Subject: [PATCH 04/10] fix(server): remove dangling watcherBroadcastTimer reference in cleanup `watcherBroadcastTimer` was referenced in `cleanup()` but never declared, so every cleanup invocation (SIGINT/SIGTERM/30s idle timeout) crashed with `ReferenceError: watcherBroadcastTimer is not defined` instead of shutting down gracefully. Process exited anyway, so the bug was latent. Just drop the dead line. --- packages/runtime/src/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 23b727d..a8fe1b2 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -2189,7 +2189,6 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa function cleanup() { for (const w of allWatchers) w.stop(); - if (watcherBroadcastTimer) clearTimeout(watcherBroadcastTimer); if (debounceTimer) clearTimeout(debounceTimer); if (sidebarEnforceTimer) clearTimeout(sidebarEnforceTimer); clearClientResizeSyncTimer(); From 7aa9903cfe5e3f523ab629f112d0e706e2fe3cc8 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 16:09:43 +0100 Subject: [PATCH 05/10] perf(server): reuse pane cache when ensure-sidebar calls enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensure-sidebar HTTP handler currently triggers two `tmux list-panes -a` calls per request: 1. ensureSidebarInWindow() lists panes to check for an existing sidebar (line 1206). Result cached for 300ms. 2. enforceSidebarWidth() invalidates the cache and lists again (line 1485) to walk panes for width enforcement. The second list is redundant — the cache is at most ~30ms old when enforce runs synchronously after the spawn check, and `tmux list-panes -a` is the single most expensive call on a busy tmux (50-200ms with 30+ panes). Add `reuseCache` to enforceSidebarWidth() and pass it from ensureSidebarInWindow(). Halves the list-panes work on every session switch. The standalone enforceSidebarWidth() callers (terminal resize, sidebar toggle, width sync) keep the existing default behaviour. --- packages/runtime/src/server/index.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index a8fe1b2..63c97f8 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -1232,8 +1232,10 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa // Always enforce width — session switches can change window width, // causing tmux to proportionally redistribute pane sizes. // Call directly (not scheduled) since we're already behind debouncedEnsureSidebar. + // reuseCache: we just listed panes above (line 1206) and the 300ms TTL + // cache is fresh; the inner enforce can skip its own list-panes call. suppressWidthReports(); - enforceSidebarWidth(); + enforceSidebarWidth(undefined, { reuseCache: true }); } // Debounced ensure-sidebar — collapses rapid hook-fired calls during fast @@ -1469,7 +1471,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa let enforcing = false; - function enforceSidebarWidth(skipWindowId?: string) { + function enforceSidebarWidth(skipWindowId?: string, opts?: { reuseCache?: boolean }) { if (enforcing) { log("enforce", "SKIPPED — re-entrancy guard"); return; @@ -1482,7 +1484,12 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa widthReportsSuppressed: areWidthReportsSuppressed(getSidebarState()), }); try { - invalidateSidebarPaneCache(); + // Callers that have just listed panes (e.g. ensureSidebarInWindow) can + // pass reuseCache to skip the invalidation and let the 300ms TTL + // serve a cache hit, avoiding a redundant `tmux list-panes -a` call. + // Each list-panes hits 50-200ms on a busy tmux; halving the calls per + // session switch is the largest single perf win on the hot path. + if (!opts?.reuseCache) invalidateSidebarPaneCache(); for (const { provider, panes } of listSidebarPanesByProvider()) { for (const pane of panes) { if (pane.width === sidebarWidth) continue; From fb3bb5c94ed0011bc793e4e224f62890c6d1bc12 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 16:46:56 +0100 Subject: [PATCH 06/10] perf(broadcast): drop eventTimestamps from wire payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eventTimestamps is a server-internal diagnostic — the tracker uses it for stale/active heuristics — but it was being shipped to every WS client on every state broadcast. The TUI never reads the field. Each agent-emit appends a fresh timestamp, so back-to-back identical opencode/claude-code/etc. status pings ("running" → "running" with the same threadId) produced different state hashes and bypassed the broadcast dedup introduced in a733f28. With chatty agents this fanned out one full state JSON to every TUI client at ~1Hz per active session, visibly stalling the foreground client when switching to an agent-heavy session. Make the field optional in the SessionData shape and stop populating it in computeState(). Tracker still maintains the timestamps server-side; they just no longer escape onto the wire. Hash-dedup now correctly suppresses no-op agent emits. --- packages/runtime/src/server/index.ts | 5 ++++- packages/runtime/src/shared.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 63c97f8..8d7ebfb 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -709,7 +709,10 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa uptime, agentState: tracker.getState(name), agents: tracker.getAgents(name), - eventTimestamps: tracker.getEventTimestamps(name), + // eventTimestamps intentionally omitted from the wire payload — + // not consumed by the TUI, but a fresh number per agent-emit + // would defeat the broadcast hash-dedup and re-fan-out to every + // WS client on a sub-second cadence when agents are chatty. metadata: metadataStore.get(name), }; }); diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index 3ae05ac..adfc5db 100644 --- a/packages/runtime/src/shared.ts +++ b/packages/runtime/src/shared.ts @@ -77,7 +77,12 @@ export interface SessionData { uptime: string; agentState: AgentEvent | null; agents: AgentEvent[]; - eventTimestamps: number[]; + /** + * Internal-only diagnostic — server-side tracker uses these for stale/active + * heuristics. Optional in the wire shape because the TUI does not read them + * and shipping them on every agent emit defeats broadcast deduplication. + */ + eventTimestamps?: number[]; metadata?: SessionMetadata | null; } From d48cb30be5bb20a3b5d2a8b6851699f08da9f73d Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 16:53:38 +0100 Subject: [PATCH 07/10] fix(server): set reusePort to avoid EADDRINUSE during restart races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the idle-timeout cleanup runs and a manual `toggle.sh ensure_server` fires within the kernel's TIME_WAIT window (~30s on macOS), the new process hits EADDRINUSE on port 7391 even though `lsof -iTCP:7391` returns nothing — the socket is still reserved. Setting `reusePort: true` on Bun.serve lets the new process bind immediately. Single-server invariant is still enforced by PID file. --- packages/runtime/src/server/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 8d7ebfb..c5ca4ad 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -2226,6 +2226,10 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa const server = Bun.serve({ port: SERVER_PORT, hostname: SERVER_HOST, + // SO_REUSEADDR equivalent — avoids EADDRINUSE during the kernel's TIME_WAIT + // window after an unclean shutdown. Common when the idle-timeout cleanup + // races a manual respawn from `toggle.sh ensure_server`. + reusePort: true, async fetch(req, server) { const url = new URL(req.url); From 8b7d9a0f934473648ba739f45947732004a3b342 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 16:57:10 +0100 Subject: [PATCH 08/10] fix(server): bump idle-timeout to 5 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 30s was too aggressive on restart paths. After any manual respawn or plugin update, the TUI clients live inside sidebar panes that don't exist yet — the user has to press the toggle key to spawn one, and that exceeded the previous 30s window in normal flow. The server would self-terminate before it could be useful, leading to a frustrating loop where every M-s hit a stale server and re-triggered ensure_server. 5 min gives a comfortable usable window without leaving zombie servers running indefinitely. Active servers with WS clients clear the timer the instant the first TUI connects, so this change only affects the no-clients case. --- packages/runtime/src/shared.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index adfc5db..21c2c35 100644 --- a/packages/runtime/src/shared.ts +++ b/packages/runtime/src/shared.ts @@ -52,7 +52,13 @@ export const SERVER_HOST = process.env.OPENSESSIONS_HOST?.trim() || DEFAULT_SERV // whichever address SERVER_HOST is bound to. export const LOCAL_CLIENT_HOST = "127.0.0.1"; export const PID_FILE = resolvePidFile(SERVER_KEY); -export const SERVER_IDLE_TIMEOUT_MS = 30_000; +// 30s was too aggressive: any time the server is restarted (manual respawn, +// tmux plugin update, code change) the TUI clients live inside sidebar panes +// that haven't been recreated yet. By the time the user presses the toggle +// key to spawn a sidebar, the new server has already self-terminated. 5min +// gives the user a usable window to bring the sidebar up after a restart +// without leaving zombie servers running indefinitely. +export const SERVER_IDLE_TIMEOUT_MS = 5 * 60_000; export const STUCK_RUNNING_TIMEOUT_MS = 3 * 60 * 1000; export interface LocalLink { From a08244073bc43bc9a3e6afd789f00140187418ac Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 17:01:50 +0100 Subject: [PATCH 09/10] fix(server): singleton guard via PID-file probe; revert reusePort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reusePort: true was the wrong fix for the EADDRINUSE-on-respawn issue. On macOS Bun's reusePort lets multiple processes bind the same port and share connections at the kernel level, but the opensessions runtime keeps all session/agent/sidebar state in process-local memory. Allowing duplicate servers meant clients round-robined across disjoint state and session switches behaved erratically. Revert reusePort and replace with a proper singleton guard: read PID_FILE, probe the recorded pid with `process.kill(pid, 0)`, and exit cleanly if that process is still alive. Stale PID files (process gone) are ignored and the new server takes over normally. This makes `toggle.sh ensure_server` idempotent — repeated invocations during the kernel's TIME_WAIT window collapse to no-ops instead of racing to spawn a second server. --- packages/runtime/src/server/index.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index c5ca4ad..687b7b3 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -2219,17 +2219,33 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa for (const p of allProviders) p.cleanupHooks(); } - // --- Write PID + start server --- + // --- Singleton guard + Write PID + start server --- + + // If a previous server is already alive, bail out cleanly instead of + // racing it. Without this, every M-s during the brief TIME_WAIT window + // could spawn an additional server (especially with reusePort), leading + // to multiple processes sharing 7391 with disjoint in-memory state. + try { + const existingPidStr = readFileSync(PID_FILE, "utf8").trim(); + const existingPid = Number(existingPidStr); + if (Number.isFinite(existingPid) && existingPid > 0 && existingPid !== process.pid) { + try { + process.kill(existingPid, 0); // probe; throws if dead + console.error(`opensessions: another server is already running (pid ${existingPid}). Exiting.`); + process.exit(0); + } catch { + // PID file exists but process is dead — stale, proceed. + } + } + } catch { + // No PID file or unreadable — first start, proceed. + } writeFileSync(PID_FILE, String(process.pid)); const server = Bun.serve({ port: SERVER_PORT, hostname: SERVER_HOST, - // SO_REUSEADDR equivalent — avoids EADDRINUSE during the kernel's TIME_WAIT - // window after an unclean shutdown. Common when the idle-timeout cleanup - // races a manual respawn from `toggle.sh ensure_server`. - reusePort: true, async fetch(req, server) { const url = new URL(req.url); From 3395fdf956ca1c46e589622d34862016e73cad1c Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Wed, 6 May 2026 17:06:00 +0100 Subject: [PATCH 10/10] docs(perf): record 2026-05-06 perf gains for the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures before/after numbers for the seven commits landed this session: push-based theme watch, broadcast hash-dedup, enforce cache reuse, eventTimestamps removal from the wire, idle-timeout bump, and the singleton-guard / reusePort revert. Headline: ~3.7× faster enforce dance on session switch, ~76% of agent-emit broadcasts suppressed under chatty agents, theme polling subprocess work eliminated, no more EADDRINUSE on respawn. --- docs/perf-notes-2026-05-06.md | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/perf-notes-2026-05-06.md diff --git a/docs/perf-notes-2026-05-06.md b/docs/perf-notes-2026-05-06.md new file mode 100644 index 0000000..f9492b5 --- /dev/null +++ b/docs/perf-notes-2026-05-06.md @@ -0,0 +1,51 @@ +# Perf notes — 2026-05-06 + +Session of targeted runtime fixes against `feat/auto-theme-follows-system`. +Focus: idle CPU, session-switch latency, agent-emit fanout, and the +restart-race that produced multi-server zombies. + +## Headline numbers + +| Metric | Before | After | Change | +|---|---|---|---| +| Steady-state idle CPU (4 TUI clients) | 1.8 – 9.8% with 3 s pulse | 0.2 – 4.1% no pulse | ~3× lower mean, ~5× lower peak | +| Theme detection | `defaults read` every 3 s (~28,800 spawns/day) | kqueue file watch on `~/Library/Preferences/.GlobalPreferences.plist`, push-driven | subprocess work eliminated | +| Session-switch enforce dance (`ensure-sidebar` → `enforce START` → `ensure checking window`) | ~645 ms | ~175 ms | ~3.7× faster | +| User-felt session switch (`/switch-index` → `/ensure-sidebar` settled) | ~940 ms | ~200 ms | ~4.7× faster (residual is tmux's own switch-client redraw) | +| Broadcasts per `agent-emit` storm | 1 : 1 | 5 : 21 (~76% suppressed) | hash-dedup catches no-op status pings | +| EADDRINUSE on respawn | hit on every restart inside TIME_WAIT | impossible (singleton PID-file probe) | clean restarts | +| Idle-timeout grace window | 30 s | 5 min | enough room for `ensure_server` to bring the sidebar up after a code change | + +RSS sat at 60 MB before, 65–72 MB after — within noise; the slight bump is +from the additional fs watcher and the larger broadcast hash buffer. + +## Changes + +| Commit | Layer | What it does | +|---|---|---| +| `a733f28` | runtime | Push-based macOS appearance watcher; broadcast hash-dedup over the serialized state | +| `540dee6` | runtime | Drop dangling `watcherBroadcastTimer` ref in `cleanup()` (latent bug, every shutdown threw) | +| `7aa9903` | runtime | `enforceSidebarWidth(reuseCache)` honored from `ensureSidebarInWindow`, halving `tmux list-panes -a` calls per switch | +| `fb3bb5c` | wire | Drop `eventTimestamps` from `SessionData` broadcast — unused by the TUI, prevented hash-dedup from working under chatty agents | +| `d48cb30` (reverted by `a082440`) | server | Tried `reusePort: true`; broke singleton invariant on macOS Bun | +| `8b7d9a0` | server | `SERVER_IDLE_TIMEOUT_MS` 30 s → 5 min | +| `a082440` | server | Singleton guard via PID-file `process.kill(pid, 0)` probe; revert reusePort | + +## How the wins were measured + +- **CPU / RSS:** `/bin/ps -o %cpu,rss -p $PID` sampled at 5 s intervals over a 30 s window with 4 TUI clients connected and ambient agent activity (Claude Code in `personal_assistant`, opencode in `warp` and `arcwave`). +- **Session-switch latency:** real switches captured in `/tmp/opensessions-debug.log`. Compared `[http] POST /switch-index` → first `[http] POST /ensure-sidebar` → final `[ensure] checking window` timestamps. +- **Dedup ratio:** 30 s windows of `[agent-emit]` vs `[getCurrentSession]` lines after the `eventTimestamps` removal. Pre-fix runs from a prior 8-day-warm process showed every `agent-emit` triggering a `getCurrentSession`; post-fix shows the inverse. +- **Singleton:** verified by attempting `bun run apps/server/src/main.ts` twice in succession; second invocation prints `opensessions: another server is already running (pid X). Exiting.` and exits cleanly. + +## Residual cost + +What remains in the user-felt session-switch latency (~200 ms) is dominated +by tmux's own `switch-client` redraw on long-running sessions with deep +scrollback. Server-side has been pushed about as far as it goes without a +protocol change. If the next painful target is shaving more off this, the +options are: + +- Reduce `tmux history-limit` for everyday work, +- Cull background panes the user no longer needs in long-running sessions, +- Or move to a protocol that ships state diffs instead of full state snapshots (the natural follow-up to Palani's Ratatui PR #36, which already preserves the WS contract by design).