From 19ec1d934ee25ad1cf00c85a12596e07c77c2ae5 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Thu, 23 Apr 2026 21:41:41 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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). From a188b371fb0b13fb636bcd2777c7a13deee1d1ab Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 8 May 2026 13:31:02 +0100 Subject: [PATCH 11/13] perf(runtime): unblock event loop + bypass server for switch action Three layered changes to bring rapid M-N session switching from ~1.5s/switch under load to ~60ms/switch: 1. Keybinding & script overhead - Bind keys via `run-shell -b` so tmux's input loop doesn't block while the shell script runs. Without -b, rapid presses serialise on tmux's command queue and reentrant tmux calls inside the script compound the wait. - Pass `#{client_tty}|#{session_name}|#{window_id}` as a positional arg from the bind-key, skipping a `tmux display-message` fork inside the script (~16ms saved). - server-common.sh: alive-check tmpfile (5s TTL) skips the ~80ms healthcheck curl on the hot path; plugin-dir / bun lookup deferred to the cold-start branch only. - Bump curl `-m 0.2` -> `-m 1.5`, `--connect-timeout 0.1` -> `0.3`, and `|| true; exit 0` so tmux's run-shell never surfaces curl exit 28 (timeout) in the status line during transient spikes. 2. Server-side event loop - Drop `invalidateCurrentSessionCache()` from broadcastStateImmediate. The 500ms TTL cache was being wiped on every broadcast (per agent-emit, dozens/sec under chatty watchers like a remote hermes plugin), defeating its purpose. handleFocus() still invalidates on real focus changes. - Swap raw getCurrentSession() -> getCachedCurrentSession() at the hot computeState/ensure-sidebar/toggle sites. - Debounce broadcastState (30ms) and refreshPaneAgents (250ms). Sub-perception windows; coalesce bursts that previously each triggered a fresh tmux subprocess (or a `tmux list-panes -a` + ps + lsof in the case of refreshPaneAgents). - Add AsyncReadCapable interface + isAsyncReadCapable guard. TmuxClient gains `runAsync` (Bun.spawn(...).exited) and async siblings: listSessionsAsync, listPanesAsync, listClientsAsync, getCurrentSessionAsync, getActiveSessionDirsAsync, getAllPaneCountsAsync. TmuxProvider implements them; its async listSessions runs the two underlying tmux subprocesses (list-sessions + active-session-dirs) in parallel via Promise.all. - computeState() and broadcastStateImmediate() are now async. Provider list calls in computeState run through Promise.all on the async API where available; legacy providers fall back to sync. broadcastStateImmediate uses an in-flight + trailing flag so concurrent triggers coalesce instead of stacking. 3. Bypass server for the switch action - Server writes ORDERING_FILE (/tmp/opensessions..ordering) on every state broadcast, one visible session name per line. - switch-index.sh fast-path: read line N from the file, call `tmux switch-client -t ` directly. The server-side POST still goes out (backgrounded) so the server can update unseen flags / sidebar focus / custom ordering, but the user-perceived latency is just one tmux fork. The existing tmux hook (client-session-changed -> POST /focus) keeps the server in sync. - Cold-path fallback: if the ordering file is missing (boot, or ordering not yet broadcast), behave as before. Measured on an interactive setup with 4 sessions, 3 attached sidebars, and a chatty remote hermes watcher pushing events 3-5/sec: before after GET / healthcheck 218ms 20ms POST /switch-index isolated 1033ms 54ms 5 parallel POSTs total timeout 32ms switch-index.sh end-to-end (warm) 270ms 60ms 5 rapid-fire switches ~1.5s ~280ms Stacked on PR #32 (perf+themes); merge order does not strictly matter but the runtime/server index.ts changes overlap. Co-authored-by: Isaac --- integrations/tmux-plugin/scripts/focus.sh | 2 +- .../tmux-plugin/scripts/server-common.sh | 40 ++++- .../tmux-plugin/scripts/switch-index.sh | 42 ++++- integrations/tmux-plugin/scripts/toggle.sh | 3 +- opensessions.tmux | 20 +-- packages/mux/contract/src/index.ts | 2 + packages/mux/contract/src/types.ts | 19 ++- packages/mux/providers/tmux/src/client.ts | 114 ++++++++++++-- packages/mux/providers/tmux/src/provider.ts | 32 +++- packages/runtime/src/contracts/mux.ts | 2 + packages/runtime/src/server/index.ts | 145 +++++++++++++----- packages/runtime/src/shared.ts | 6 + 12 files changed, 358 insertions(+), 69 deletions(-) diff --git a/integrations/tmux-plugin/scripts/focus.sh b/integrations/tmux-plugin/scripts/focus.sh index 74c1709..6f81179 100644 --- a/integrations/tmux-plugin/scripts/focus.sh +++ b/integrations/tmux-plugin/scripts/focus.sh @@ -21,7 +21,7 @@ ensure_server || exit 0 CTX="$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null)" -curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" +curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" >/dev/null 2>&1 || true attempt=0 while [ "$attempt" -lt 20 ]; do diff --git a/integrations/tmux-plugin/scripts/server-common.sh b/integrations/tmux-plugin/scripts/server-common.sh index eec9dd0..7484859 100644 --- a/integrations/tmux-plugin/scripts/server-common.sh +++ b/integrations/tmux-plugin/scripts/server-common.sh @@ -43,12 +43,18 @@ else PID_FILE="/tmp/opensessions.pid" fi -PLUGIN_DIR="$(tmux show-environment -g OPENSESSIONS_DIR 2>/dev/null | cut -d= -f2)" -PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$SCRIPT_DIR/../../.." && pwd)}" -BUN_PATH="${BUN_PATH:-$(command -v bun 2>/dev/null || echo "$HOME/.bun/bin/bun")}" -SERVER_ENTRY="$PLUGIN_DIR/apps/server/src/main.ts" +# Defer plugin-dir / bun lookup until the cold-start path actually needs it. +# tmux show-environment forks tmux (~10-20ms); command -v bun is cheap but +# the conditional below ensures hot paths (alive cache hit) skip both. SERVER_LOG="/tmp/opensessions-server.log" +resolve_cold_start_paths() { + PLUGIN_DIR="$(tmux show-environment -g OPENSESSIONS_DIR 2>/dev/null | cut -d= -f2)" + PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$SCRIPT_DIR/../../.." && pwd)}" + BUN_PATH="${BUN_PATH:-$(command -v bun 2>/dev/null || echo "$HOME/.bun/bin/bun")}" + SERVER_ENTRY="$PLUGIN_DIR/apps/server/src/main.ts" +} + show_startup_error() { message="$1" tmux display-message "$message" >/dev/null 2>&1 || true @@ -59,11 +65,36 @@ server_alive() { curl -s -o /dev/null -m 0.2 "http://${HOST}:${PORT}/" 2>/dev/null } +# Cache the alive-check result. If we confirmed the server alive within +# ALIVE_CACHE_TTL_S seconds, trust it and skip the ~80ms curl. Hot keypress +# paths (switch-index, focus, toggle) all call ensure_server first; without +# this they each pay the curl tax even though the server is fine. +ALIVE_CACHE_FILE="${PID_FILE%.pid}.alive" +ALIVE_CACHE_TTL_S=5 + +alive_cache_fresh() { + [ -f "$ALIVE_CACHE_FILE" ] || return 1 + # Cross-platform mtime delta. macOS stat -f %m, Linux stat -c %Y. + now=$(date +%s) + mtime=$(stat -f %m "$ALIVE_CACHE_FILE" 2>/dev/null || stat -c %Y "$ALIVE_CACHE_FILE" 2>/dev/null) + [ -n "$mtime" ] || return 1 + delta=$((now - mtime)) + [ "$delta" -lt "$ALIVE_CACHE_TTL_S" ] +} + ensure_server() { + if alive_cache_fresh; then + return 0 + fi + if server_alive; then + : > "$ALIVE_CACHE_FILE" 2>/dev/null return 0 fi + # Cold start: only now do we need plugin dir + bun resolution + resolve_cold_start_paths + if [ ! -x "$BUN_PATH" ]; then show_startup_error "opensessions: bun not found. Install bun and retry." return 1 @@ -75,6 +106,7 @@ ensure_server() { while [ "$attempt" -lt 30 ]; do sleep 0.1 if server_alive; then + : > "$ALIVE_CACHE_FILE" 2>/dev/null return 0 fi attempt=$((attempt + 1)) diff --git a/integrations/tmux-plugin/scripts/switch-index.sh b/integrations/tmux-plugin/scripts/switch-index.sh index 48436a7..fb318bb 100755 --- a/integrations/tmux-plugin/scripts/switch-index.sh +++ b/integrations/tmux-plugin/scripts/switch-index.sh @@ -1,13 +1,45 @@ #!/usr/bin/env sh # Switch to the Nth visible opensessions session (1-indexed). +# +# Args: +# $1 = index (required) +# $2 = pre-expanded ctx string "client_tty|session|window_id" (optional) +# When $2 is supplied (from tmux format-string expansion at bind-key time), +# we skip the ~16ms `tmux display-message` fork. -INDEX="${1:?Usage: switch-index.sh }" +INDEX="${1:?Usage: switch-index.sh [ctx]}" +CTX="${2:-}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/server-common.sh" -ensure_server || exit 0 +# Fast path: read the ordering file the server writes on every state change +# and call `tmux switch-client -t ` directly. This avoids a round-trip +# through the server's HTTP handler and its own synchronous tmux fork — +# user-perceived latency drops to one tmux subprocess fork (~30-50ms) plus +# shell overhead. The server still gets notified asynchronously via tmux +# hooks (client-session-changed → POST /focus). +ORDERING_FILE="${PID_FILE%.pid}.ordering" +if [ -f "$ORDERING_FILE" ]; then + TARGET=$(awk -v idx="$INDEX" 'NR == idx { print; exit }' "$ORDERING_FILE") + if [ -n "$TARGET" ]; then + tmux switch-client -t "$TARGET" >/dev/null 2>&1 + # Fire-and-forget POST so the server can update side effects (sidebar + # focus, agent unseen flags, custom ordering) async. Timeout generous; + # exit code swallowed so tmux's status line never shows curl errors. + if [ -z "$CTX" ]; then + CTX="|$TARGET|" + fi + (curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX" >/dev/null 2>&1 || true) & + exit 0 + fi +fi -CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null) -curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX" -tmux switch-client -T root >/dev/null 2>&1 +# Cold path: ordering file missing or empty. Server hasn't broadcast yet +# (cold boot). Fall back to the original server-mediated switch. +ensure_server || exit 0 +if [ -z "$CTX" ]; then + CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null) +fi +curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX" >/dev/null 2>&1 || true +exit 0 diff --git a/integrations/tmux-plugin/scripts/toggle.sh b/integrations/tmux-plugin/scripts/toggle.sh index df615ab..93ddee4 100755 --- a/integrations/tmux-plugin/scripts/toggle.sh +++ b/integrations/tmux-plugin/scripts/toggle.sh @@ -7,5 +7,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ensure_server || exit 0 CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null) -curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" +curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" >/dev/null 2>&1 || true tmux switch-client -T root >/dev/null 2>&1 +exit 0 diff --git a/opensessions.tmux b/opensessions.tmux index e6c1a0a..bbf5fab 100755 --- a/opensessions.tmux +++ b/opensessions.tmux @@ -44,7 +44,7 @@ bind_global_key() { local key="$1" local command="$2" [ -n "$key" ] || return - tmux bind-key -n "$key" run-shell "$command" + tmux bind-key -n "$key" run-shell -b "$command" } bind_global_index_keys() { @@ -52,7 +52,7 @@ bind_global_index_keys() { local key for key in $INDEX_KEYS; do [ "$index" -le 9 ] || break - tmux bind-key -n "$key" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $index" + tmux bind-key -n "$key" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $index '#{client_tty}|#{session_name}|#{window_id}'" index=$((index + 1)) done } @@ -88,21 +88,23 @@ fi if [ -n "$PREFIX_KEY" ]; then tmux bind-key "$PREFIX_KEY" switch-client -T "$COMMAND_TABLE" tmux bind-key -T "$COMMAND_TABLE" Any switch-client -T root - tmux bind-key -T "$COMMAND_TABLE" s run-shell "sh '$SCRIPTS_DIR/focus.sh'" - tmux bind-key -T "$COMMAND_TABLE" t run-shell "sh '$SCRIPTS_DIR/toggle.sh'" - tmux bind-key -T "$COMMAND_TABLE" e run-shell "sh '$SCRIPTS_DIR/even-horizontal.sh' '#{window_id}' '#{pane_id}'" + tmux bind-key -T "$COMMAND_TABLE" s run-shell -b "sh '$SCRIPTS_DIR/focus.sh'" + tmux bind-key -T "$COMMAND_TABLE" t run-shell -b "sh '$SCRIPTS_DIR/toggle.sh'" + tmux bind-key -T "$COMMAND_TABLE" e run-shell -b "sh '$SCRIPTS_DIR/even-horizontal.sh' '#{window_id}' '#{pane_id}'" + # Pass tmux-expanded ctx as $2 so the script doesn't need to fork + # `tmux display-message`. Then immediately reset keytable. for i in 1 2 3 4 5 6 7 8 9; do - tmux bind-key -T "$COMMAND_TABLE" "$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i" + tmux bind-key -T "$COMMAND_TABLE" "$i" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $i '#{client_tty}|#{session_name}|#{window_id}'" \; switch-client -T root done fi # Direct prefix bindings for programmatic use (terminal emulator shortcuts). # C-s/C-t are single-byte Ctrl codes; M-1..9 are 2-byte Alt sequences. # Both are safe to send as text from terminal emulators without timing issues. -tmux bind-key C-s run-shell "sh '$SCRIPTS_DIR/focus.sh'" -tmux bind-key C-t run-shell "sh '$SCRIPTS_DIR/toggle.sh'" +tmux bind-key C-s run-shell -b "sh '$SCRIPTS_DIR/focus.sh'" +tmux bind-key C-t run-shell -b "sh '$SCRIPTS_DIR/toggle.sh'" for i in 1 2 3 4 5 6 7 8 9; do - tmux bind-key "M-$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i" + tmux bind-key "M-$i" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $i '#{client_tty}|#{session_name}|#{window_id}'" done bind_global_key "$FOCUS_GLOBAL_KEY" "sh '$SCRIPTS_DIR/focus.sh'" diff --git a/packages/mux/contract/src/index.ts b/packages/mux/contract/src/index.ts index 8080b63..7ab30f6 100644 --- a/packages/mux/contract/src/index.ts +++ b/packages/mux/contract/src/index.ts @@ -10,6 +10,7 @@ export type { WindowCapable, SidebarCapable, BatchCapable, + AsyncReadCapable, FullMuxProvider, MuxProvider, MuxProviderSettings, @@ -20,5 +21,6 @@ export { isWindowCapable, isSidebarCapable, isBatchCapable, + isAsyncReadCapable, isFullSidebarCapable, } from "./types"; diff --git a/packages/mux/contract/src/types.ts b/packages/mux/contract/src/types.ts index 0a0e1fa..c5acb64 100644 --- a/packages/mux/contract/src/types.ts +++ b/packages/mux/contract/src/types.ts @@ -95,6 +95,18 @@ export interface BatchCapable { getAllPaneCounts(): Map; } +/** + * Async-capable read methods for hot paths in the runtime. Providers that + * implement these allow the server to await tmux/mux subprocess work + * instead of blocking the event loop. The sync siblings remain available + * as fallbacks for non-async call sites. + */ +export interface AsyncReadCapable { + listSessionsAsync(): Promise; + getCurrentSessionAsync(): Promise; + getAllPaneCountsAsync?(): Promise>; +} + // ─── Composite types ───────────────────────────────────────────────────────── /** @@ -108,7 +120,7 @@ export type FullMuxProvider = MuxProviderV1 & WindowCapable & SidebarCapable & B * * Like ai-sdk's LanguageModel = V2 | V3 | V4 — accepts any level of capability. */ -export type MuxProvider = MuxProviderV1 & Partial; +export type MuxProvider = MuxProviderV1 & Partial; // ─── Type guards ───────────────────────────────────────────────────────────── // Runtime narrowing — like ai-sdk's isInstance() pattern, but for capabilities. @@ -136,6 +148,11 @@ export function isBatchCapable(p: MuxProvider): p is MuxProviderV1 & BatchCapabl return typeof p.getAllPaneCounts === "function"; } +/** Check if a provider implements the async read API */ +export function isAsyncReadCapable(p: MuxProvider): p is MuxProviderV1 & AsyncReadCapable { + return typeof p.listSessionsAsync === "function" && typeof p.getCurrentSessionAsync === "function"; +} + /** Check if a provider supports full sidebar management (window + sidebar) */ export function isFullSidebarCapable( p: MuxProvider, diff --git a/packages/mux/providers/tmux/src/client.ts b/packages/mux/providers/tmux/src/client.ts index c4be398..2b576a8 100644 --- a/packages/mux/providers/tmux/src/client.ts +++ b/packages/mux/providers/tmux/src/client.ts @@ -136,6 +136,20 @@ export interface SplitWindowOptions { /** Field delimiter — tab character, universally supported by tmux */ const SEP = "\t"; +/** Shared parser used by getActiveSessionDirs() sync + async variants */ +function parseActiveSessionDirsOutput(stdout: string, dirs: Map): Map { + if (!stdout) return dirs; + for (const line of stdout.split("\n")) { + if (!line) continue; + const sep = line.indexOf(SEP); + if (sep < 0) continue; + const session = line.slice(0, sep); + const cwd = line.slice(sep + 1); + if (!dirs.has(session)) dirs.set(session, cwd); + } + return dirs; +} + type Parser = (raw: string) => T; const str: Parser = (s) => s; @@ -270,6 +284,42 @@ export class TmuxClient { } } + /** + * Async sibling of run(). Uses Bun.spawn (non-blocking) so callers can + * await the tmux subprocess without blocking the bun event loop. Used by + * hot read-only paths in the runtime where sync would block all incoming + * HTTP traffic during the tmux fork. + */ + async runAsync(args: readonly string[], options?: { throwOnError?: boolean }): Promise { + const fullArgs = [this.bin, ...this.globalArgs, ...args]; + const shouldThrow = options?.throwOnError ?? this.throwOnError; + + try { + const proc = Bun.spawn(fullArgs, { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const out: TmuxRunResult = { + args: fullArgs, + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + ok: exitCode === 0, + }; + if (!out.ok && shouldThrow) throw new TmuxError(out); + return out; + } catch (e) { + if (e instanceof TmuxError) throw e; + return { + args: fullArgs, + exitCode: -1, + stdout: "", + stderr: e instanceof Error ? e.message : String(e), + ok: false, + }; + } + } + // ─── Sessions ────────────────────────────────────── listSessions(): SessionInfo[] { @@ -277,6 +327,11 @@ export class TmuxClient { return parseRows(SESSION_SPEC, stdout); } + async listSessionsAsync(): Promise { + const { stdout } = await this.runAsync(["list-sessions", "-F", SESSION_FORMAT]); + return parseRows(SESSION_SPEC, stdout); + } + newSession(options: { name?: string; cwd?: string; detached?: boolean } = {}): string { const args = ["new-session"]; if (options.detached !== false) args.push("-d"); @@ -325,6 +380,20 @@ export class TmuxClient { return parseRows(PANE_SPEC, stdout); } + async listPanesAsync(options?: PaneScope): Promise { + const args = ["list-panes"]; + if (!options || !options.scope || options.scope === "all") { + args.push("-a"); + } else if (options.scope === "session") { + args.push("-s", "-t", options.target); + } else if (options.scope === "window") { + args.push("-t", options.target); + } + args.push("-F", PANE_FORMAT); + const { stdout } = await this.runAsync(args); + return parseRows(PANE_SPEC, stdout); + } + splitWindow(options: SplitWindowOptions): PaneInfo | null { const args = ["split-window"]; if (options.direction === "horizontal" || !options.direction) { @@ -381,10 +450,19 @@ export class TmuxClient { return parseRows(CLIENT_SPEC, stdout); } + async listClientsAsync(): Promise { + const { stdout } = await this.runAsync(["list-clients", "-F", CLIENT_FORMAT]); + return parseRows(CLIENT_SPEC, stdout); + } + private getInteractiveClients(): ClientInfo[] { return this.listClients().filter((client) => client.tty.length > 0); } + private async getInteractiveClientsAsync(): Promise { + return (await this.listClientsAsync()).filter((client) => client.tty.length > 0); + } + switchClient(target: string, options?: { clientTty?: string }): void { const args = ["switch-client"]; if (options?.clientTty) args.push("-c", options.clientTty); @@ -421,6 +499,12 @@ export class TmuxClient { return clients[0]!.sessionName || null; } + async getCurrentSessionAsync(): Promise { + const clients = await this.getInteractiveClientsAsync(); + if (clients.length === 0) return null; + return clients[0]!.sessionName || null; + } + /** * Get the client TTY for the current client */ @@ -455,16 +539,17 @@ export class TmuxClient { "-f", "#{&&:#{window_active},#{!=:#{pane_title},opensessions-sidebar}}", "-F", `#{session_name}${SEP}#{pane_current_path}`, ]); - if (!stdout) return dirs; - for (const line of stdout.split("\n")) { - if (!line) continue; - const sep = line.indexOf(SEP); - if (sep < 0) continue; - const session = line.slice(0, sep); - const cwd = line.slice(sep + 1); - if (!dirs.has(session)) dirs.set(session, cwd); - } - return dirs; + return parseActiveSessionDirsOutput(stdout, dirs); + } + + async getActiveSessionDirsAsync(): Promise> { + const dirs = new Map(); + const { stdout } = await this.runAsync([ + "list-panes", "-a", + "-f", "#{&&:#{window_active},#{!=:#{pane_title},opensessions-sidebar}}", + "-F", `#{session_name}${SEP}#{pane_current_path}`, + ]); + return parseActiveSessionDirsOutput(stdout, dirs); } /** @@ -480,6 +565,15 @@ export class TmuxClient { return counts; } + async getAllPaneCountsAsync(): Promise> { + const counts = new Map(); + const panes = await this.listPanesAsync({ scope: "all" }); + for (const p of panes) { + counts.set(p.sessionName, (counts.get(p.sessionName) ?? 0) + 1); + } + return counts; + } + // ─── Popups ──────────────────────────────────────── displayPopup(options: { diff --git a/packages/mux/providers/tmux/src/provider.ts b/packages/mux/providers/tmux/src/provider.ts index 55b2d3d..6f01cf7 100644 --- a/packages/mux/providers/tmux/src/provider.ts +++ b/packages/mux/providers/tmux/src/provider.ts @@ -7,6 +7,7 @@ import type { WindowCapable, SidebarCapable, BatchCapable, + AsyncReadCapable, } from "@opensessions/mux"; import { TmuxClient } from "./client"; import { appendFileSync } from "fs"; @@ -36,7 +37,7 @@ function rawTmux(args: string[]): string { const STASH_SESSION = "_os_stash"; const SIDEBAR_PANE_TITLE = "opensessions-sidebar"; -export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapable, BatchCapable { +export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapable, BatchCapable, AsyncReadCapable { readonly specificationVersion = "v1" as const; readonly name: string; @@ -56,6 +57,27 @@ export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapabl })); } + /** + * Async sibling of listSessions(). Runs the two tmux subprocesses + * (list-sessions + active-session-dirs) in parallel, and uses non-blocking + * spawn so the bun event loop can serve other HTTP requests while tmux + * runs. Used by the runtime in computeState() to avoid blocking on every + * agent-emit broadcast. + */ + async listSessionsAsync(): Promise { + const [rawSessions, activeDirs] = await Promise.all([ + tmux.listSessionsAsync(), + tmux.getActiveSessionDirsAsync(), + ]); + const sessions = rawSessions.filter((s) => s.name !== STASH_SESSION); + return sessions.map((s) => ({ + name: s.name, + createdAt: s.createdAt, + dir: activeDirs.get(s.name) ?? s.dir, + windows: s.windowCount, + })); + } + switchSession(name: string, clientTty?: string): void { tmux.switchClient(name, clientTty ? { clientTty } : undefined); } @@ -64,6 +86,10 @@ export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapabl return tmux.getCurrentSession(); } + async getCurrentSessionAsync(): Promise { + return tmux.getCurrentSessionAsync(); + } + getSessionDir(name: string): string { return tmux.getSessionDir(name); } @@ -125,6 +151,10 @@ export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapabl return tmux.getAllPaneCounts(); } + async getAllPaneCountsAsync(): Promise> { + return tmux.getAllPaneCountsAsync(); + } + listActiveWindows(): ActiveWindow[] { return tmux.listWindows() .filter((w) => w.sessionName !== STASH_SESSION) diff --git a/packages/runtime/src/contracts/mux.ts b/packages/runtime/src/contracts/mux.ts index 59252f1..6606811 100644 --- a/packages/runtime/src/contracts/mux.ts +++ b/packages/runtime/src/contracts/mux.ts @@ -9,6 +9,7 @@ export type { WindowCapable, SidebarCapable, BatchCapable, + AsyncReadCapable, FullMuxProvider, MuxProvider, MuxProviderSettings, @@ -18,5 +19,6 @@ export { isWindowCapable, isSidebarCapable, isBatchCapable, + isAsyncReadCapable, isFullSidebarCapable, } from "@opensessions/mux"; diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 687b7b3..8f83ba0 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -1,8 +1,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync, appendFileSync, watch, type FSWatcher } from "fs"; import { join } from "path"; import { homedir } from "os"; -import type { MuxProvider } from "../contracts/mux"; -import { isFullSidebarCapable, isBatchCapable } from "../contracts/mux"; +import type { MuxProvider, BatchCapable } from "../contracts/mux"; +import { isFullSidebarCapable, isBatchCapable, isAsyncReadCapable } from "../contracts/mux"; import type { AgentEvent, AgentStatus, PanePresenceInput } from "../contracts/agent"; import type { AgentThreadOwner, AgentWatcher, AgentWatcherContext } from "../contracts/agent-watcher"; import { AgentTracker } from "../agents/tracker"; @@ -42,6 +42,7 @@ import { SERVER_HOST, LOCAL_CLIENT_HOST, PID_FILE, + ORDERING_FILE, SERVER_IDLE_TIMEOUT_MS, STUCK_RUNNING_TIMEOUT_MS, } from "../shared"; @@ -642,11 +643,18 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa return null; } - function computeState(): ServerState { - // Merge sessions from all providers + async function computeState(): Promise { + // Merge sessions from all providers. Run async-capable providers in + // parallel so the bun event loop keeps serving HTTP during the tmux + // forks. Falls back to sync listSessions() for providers that don't + // implement the async API (e.g. legacy zellij). + const sessionLists = await Promise.all( + allProviders.map((p) => isAsyncReadCapable(p) ? p.listSessionsAsync() : Promise.resolve(p.listSessions())), + ); const allMuxSessions: (import("../contracts/mux").MuxSessionInfo & { provider: MuxProvider })[] = []; - for (const p of allProviders) { - for (const s of p.listSessions()) { + for (let i = 0; i < allProviders.length; i++) { + const p = allProviders[i]!; + for (const s of sessionLists[i]!) { allMuxSessions.push({ ...s, provider: p }); } } @@ -655,7 +663,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa return a.name.localeCompare(b.name); }); - const currentSession = getCurrentSession(); + const currentSession = getCachedCurrentSession(); // Sync custom ordering with current session list sessionOrder.sync(allMuxSessions.map((s) => s.name)); @@ -669,12 +677,17 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa const orderedMuxSessions = orderedNames.map((n) => sessionByName.get(n)!); const portlessState = loadPortlessState(); - // Batch pane counts per provider (uses BatchCapable type guard) + // Batch pane counts per provider in parallel via async API where + // available; sync fallback for legacy providers. + const batchProviders = allProviders.filter((p): p is typeof p & BatchCapable => isBatchCapable(p)); + const paneCountResults = await Promise.all(batchProviders.map((p) => + isAsyncReadCapable(p) && p.getAllPaneCountsAsync + ? p.getAllPaneCountsAsync() + : Promise.resolve(p.getAllPaneCounts()), + )); const paneCountMaps = new Map>(); - for (const p of allProviders) { - if (isBatchCapable(p)) { - paneCountMaps.set(p, p.getAllPaneCounts()); - } + for (let i = 0; i < batchProviders.length; i++) { + paneCountMaps.set(batchProviders[i]!, paneCountResults[i]!); } const sessions: SessionData[] = orderedMuxSessions.map(({ name, createdAt, windows, dir, provider }) => { @@ -741,7 +754,6 @@ 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 @@ -750,26 +762,70 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa // to all WS clients without changing the wire protocol. let lastBroadcastHash: bigint | null = null; + // 30ms debounce window — below human perception (~100ms) but enough to + // coalesce bursts of agent-emit broadcasts (e.g. hermes pushing + // tool-running → running → tool-running within the same render frame). + // Each broadcastStateImmediate() forks tmux 3+ times via computeState(); + // collapsing 5 events to 1 within 30ms saves ~12 tmux forks per burst. + const BROADCAST_DEBOUNCE_MS = 30; + let broadcastDebounceTimer: ReturnType | null = null; function broadcastState() { - if (broadcastPending) return; - broadcastPending = true; - queueMicrotask(() => { - broadcastPending = false; + if (broadcastDebounceTimer) return; + broadcastDebounceTimer = setTimeout(() => { + broadcastDebounceTimer = null; broadcastStateImmediate(); - }); - } - - function broadcastStateImmediate() { - invalidateCurrentSessionCache(); - tracker.pruneStuck(STUCK_RUNNING_TIMEOUT_MS); - tracker.pruneTerminal(); - 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); + }, BROADCAST_DEBOUNCE_MS); + } + + // Coalesce concurrent broadcastStateImmediate calls — if a broadcast is + // already in flight (awaiting computeState), don't kick off another one. + // The trailing call sets a flag to re-broadcast once the in-flight one + // resolves, ensuring we capture state changes that arrive mid-compute. + let broadcastInFlight = false; + let broadcastTrailing = false; + async function broadcastStateImmediate(): Promise { + if (broadcastInFlight) { + broadcastTrailing = true; + return; + } + broadcastInFlight = true; + try { + // Note: do NOT invalidate currentSession cache here. The cache is only + // 500ms TTL and is explicitly invalidated on real focus changes via + // handleFocus(). Invalidating on every broadcast (which is per + // agent-emit, i.e. dozens/sec under chatty watchers like hermes) + // forces a tmux subprocess fork inside computeState() and saturates + // the bun event loop. + tracker.pruneStuck(STUCK_RUNNING_TIMEOUT_MS); + tracker.pruneTerminal(); + lastState = await computeState(); + syncGitWatchers(lastState.sessions, broadcastState); + const msg = JSON.stringify(lastState); + const hash = Bun.hash(msg); + if (hash !== lastBroadcastHash) { + lastBroadcastHash = hash; + server.publish("sidebar", msg); + } + // Write the visible session ordering to a tmpfile for the tmux + // keybinding scripts to read. This lets switch-index.sh call + // `tmux switch-client -t ` directly without round-tripping + // through the server — the user-perceived latency drops to just + // tmux's own switch time (~30-50ms) instead of waiting on the + // server to fork its own tmux subprocess. + try { + const orderedNames = lastState.sessions.map((s) => s.name).join("\n"); + writeFileSync(ORDERING_FILE, orderedNames + "\n"); + } catch { /* non-fatal */ } + } finally { + broadcastInFlight = false; + if (broadcastTrailing) { + broadcastTrailing = false; + // Re-enter — without a microtask break this would unbound-recurse + // on persistent broadcast pressure; queueMicrotask gives the event + // loop a chance to process pending I/O between iterations. + queueMicrotask(() => { void broadcastStateImmediate(); }); + } + } } // Lightweight current-session cache — avoids a tmux subprocess per focus update @@ -869,11 +925,26 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa broadcastFocusOnly(sender); } + // Coalesce refreshPaneAgents() calls during rapid focus changes. Each + // call shells out to `tmux list-panes -a` plus a process-tree match + // (50-200ms total). When the user fires 5 rapid switches, this would + // serialize 5 expensive scans on the event loop. The 250ms debounce + // collapses bursts into one scan. + const REFRESH_PANE_AGENTS_DEBOUNCE_MS = 250; + let refreshPaneAgentsTimer: ReturnType | null = null; + function scheduleRefreshPaneAgents(): void { + if (refreshPaneAgentsTimer) return; + refreshPaneAgentsTimer = setTimeout(() => { + refreshPaneAgentsTimer = null; + refreshPaneAgents(); + }, REFRESH_PANE_AGENTS_DEBOUNCE_MS); + } + function handleFocus(name: string): void { focusedSession = name; invalidateCurrentSessionCache(); - // Rescan pane agents when session focus changes - refreshPaneAgents(); + // Rescan pane agents when session focus changes (debounced) + scheduleRefreshPaneAgents(); const hadUnseen = tracker.handleFocus(name); if (hadUnseen && lastState) { // Patch unseen flags in-place — avoids a full computeState with many subprocesses @@ -1100,7 +1171,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa // 1. Current active window (instant) // 2. Other windows in the current session // 3. Windows in other sessions (staggered) - const curSession = ctx?.session ?? getCurrentSession(); + const curSession = ctx?.session ?? getCachedCurrentSession(); // Track max delay to know when all spawns are done let maxDelay = 0; @@ -1185,7 +1256,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa return; } - const curSession = ctx?.session ?? getCurrentSession(); + const curSession = ctx?.session ?? getCachedCurrentSession(); if (!curSession) { log("ensure", "SKIP — no current session"); return; @@ -2364,7 +2435,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa return new Response("invalid pi runtime payload", { status: 400 }); } piLiveResolver.upsert(parsed); - if (clientCount > 0) refreshPaneAgents(); + if (clientCount > 0) scheduleRefreshPaneAgents(); return new Response(null, { status: 204 }); } catch { return new Response("invalid json", { status: 400 }); @@ -2378,7 +2449,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa return new Response("missing pid", { status: 400 }); } piLiveResolver.delete(body.pid); - if (clientCount > 0) refreshPaneAgents(); + if (clientCount > 0) scheduleRefreshPaneAgents(); return new Response(null, { status: 204 }); } catch { return new Response("invalid json", { status: 400 }); diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index 21c2c35..e9aa48c 100644 --- a/packages/runtime/src/shared.ts +++ b/packages/runtime/src/shared.ts @@ -44,6 +44,11 @@ function resolvePidFile(serverKey: string | null): string { return `/tmp/opensessions.${serverKey}.pid`; } +function resolveOrderingFile(serverKey: string | null): string { + if (!serverKey) return "/tmp/opensessions.ordering"; + return `/tmp/opensessions.${serverKey}.ordering`; +} + export const SERVER_KEY = resolveServerKey(); export const SERVER_PORT = resolveServerPort(SERVER_KEY); export const SERVER_HOST = process.env.OPENSESSIONS_HOST?.trim() || DEFAULT_SERVER_HOST; @@ -52,6 +57,7 @@ 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 ORDERING_FILE = resolveOrderingFile(SERVER_KEY); // 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 From 2b000dd365b63e84f0c59aaeedff2264cd44b14c Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Fri, 29 May 2026 10:40:28 +0100 Subject: [PATCH 12/13] wip(runtime): two-tier agent prune, opencode eviction, 2 themes, base bg Preserve uncommitted work before porting these concepts onto the Rust/ratatui rewrite: - tracker: hard-prune unseen+terminal agents after 15min (was: never pruned unseen) - opencode watcher: evict locally-cached sessions after 15min with no DB hit - themes: add tokyo-night-storm + tango-adapted - tui: header bg crust -> base Co-authored-by: Isaac --- apps/tui/src/index.tsx | 2 +- packages/runtime/src/agents/tracker.ts | 18 ++++++---- .../runtime/src/agents/watchers/opencode.ts | 17 +++++++++ packages/runtime/src/themes.ts | 36 +++++++++++++++++++ packages/runtime/test/agent-tracker.test.ts | 21 +++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index f93472b..b0743da 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -882,7 +882,7 @@ function App() { const isFocused = createSelector(focusedSession); return ( - + {/* Header */} diff --git a/packages/runtime/src/agents/tracker.ts b/packages/runtime/src/agents/tracker.ts index bf38dd1..7689ba0 100644 --- a/packages/runtime/src/agents/tracker.ts +++ b/packages/runtime/src/agents/tracker.ts @@ -3,6 +3,7 @@ import { TERMINAL_STATUSES } from "../contracts/agent"; const MAX_EVENT_TIMESTAMPS = 30; const TERMINAL_PRUNE_MS = 5 * 60 * 1000; +const TERMINAL_HARD_PRUNE_MS = 15 * 60 * 1000; const SYNTHETIC_PANE_MARKER = ":pane:"; const STATUS_PRIORITY: Record = { @@ -231,18 +232,23 @@ export class AgentTracker { } } - /** Auto-prune terminal instances older than timeout, but only if instance is not unseen or alive */ + /** Auto-prune terminal instances. Two-tier: + * - Seen + non-alive: prune after TERMINAL_PRUNE_MS (5 min). + * - Unseen + non-alive: prune after TERMINAL_HARD_PRUNE_MS (15 min) regardless of unseen. + * - Alive (pane-backed) instances are never pruned here — they're cleared via pane events. */ pruneTerminal(): void { const now = Date.now(); for (const [session, sessionInstances] of this.instances) { for (const [key, event] of sessionInstances) { if (!TERMINAL_STATUSES.has(event.status)) continue; + if (event.liveness === "alive") continue; const ukey = this.unseenKey(session, key); - if (this.unseenInstances.has(ukey)) continue; // Don't prune unseen — user hasn't looked yet - if (event.liveness === "alive") continue; // Don't prune agents backed by live panes - if (now - event.ts > TERMINAL_PRUNE_MS) { - sessionInstances.delete(key); - } + const age = now - event.ts; + const isUnseen = this.unseenInstances.has(ukey); + if (age <= TERMINAL_PRUNE_MS) continue; + if (isUnseen && age <= TERMINAL_HARD_PRUNE_MS) continue; + sessionInstances.delete(key); + this.unseenInstances.delete(ukey); } if (sessionInstances.size === 0) { this.instances.delete(session); diff --git a/packages/runtime/src/agents/watchers/opencode.ts b/packages/runtime/src/agents/watchers/opencode.ts index 76965ed..9127b4e 100644 --- a/packages/runtime/src/agents/watchers/opencode.ts +++ b/packages/runtime/src/agents/watchers/opencode.ts @@ -136,6 +136,11 @@ const POLL_MS = 3000; const STALE_MS = 5 * 60 * 1000; /** How long a "running" session can go without DB updates before we assume the process died */ const STUCK_MS = 15_000; +/** Drop a session from the local snapshot Map after this long without a DB hit. + * Independent of the tracker's own prune logic — this just keeps memory bounded + * and ensures a reappearing session re-seeds cleanly rather than diffing against + * a stale snapshot. */ +const LOCAL_EVICT_MS = 15 * 60 * 1000; // --- Status detection --- @@ -310,7 +315,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher { } // --- Incremental: detect changes via time_updated --- + const seenThisCycle = new Set(); for (const row of rows) { + seenThisCycle.add(row.id); const prev = this.sessions.get(row.id); if (prev && prev.lastTimestamp === row.time_updated) { @@ -341,6 +348,16 @@ export class OpenCodeAgentWatcher implements AgentWatcher { this.emitStatus(row.id, snapshot); } } + + // Evict locally-cached sessions whose DB row hasn't been touched in a long + // time. The tracker handles UI pruning separately; this just bounds memory + // and ensures reappearances re-seed cleanly. + for (const [sessionId, snapshot] of this.sessions) { + if (seenThisCycle.has(sessionId)) continue; + if (now - snapshot.lastGrowthAt >= LOCAL_EVICT_MS) { + this.sessions.delete(sessionId); + } + } } finally { this.polling = false; } diff --git a/packages/runtime/src/themes.ts b/packages/runtime/src/themes.ts index b05a344..13a920d 100644 --- a/packages/runtime/src/themes.ts +++ b/packages/runtime/src/themes.ts @@ -352,6 +352,40 @@ const SHADES_OF_PURPLE: Theme = { icons: CATPPUCCIN_MOCHA.icons, }; +const TOKYO_NIGHT_STORM: Theme = { + palette: { + blue: "#7aa2f7", lavender: "#bb9af7", pink: "#bb9af7", mauve: "#bb9af7", + yellow: "#e0af68", green: "#9ece6a", red: "#f7768e", peach: "#ff9e64", + teal: "#73daca", sky: "#7dcfff", + text: "#c0caf5", subtext0: "#a9b1d6", subtext1: "#9aa5ce", + overlay0: "#4e5575", overlay1: "#3b4261", + surface0: "#292e42", surface1: "#343a52", surface2: "#414868", + base: "#24283b", mantle: "#1f2335", crust: "#1d202f", + }, + status: { + idle: "#4e5575", running: "#e0af68", "tool-running": "#7dcfff", done: "#9ece6a", + error: "#f7768e", waiting: "#7aa2f7", interrupted: "#ff9e64", stale: "#e0af68", + }, + icons: CATPPUCCIN_MOCHA.icons, +}; + +const TANGO_ADAPTED: Theme = { + palette: { + blue: "#00a2ff", lavender: "#c17ecc", pink: "#e9a7e1", mauve: "#c17ecc", + yellow: "#e3be00", green: "#59d600", red: "#ff0000", peach: "#ce5c00", + teal: "#00d0d6", sky: "#88c9ff", + text: "#000000", subtext0: "#3c3c3c", subtext1: "#555555", + overlay0: "#8f928b", overlay1: "#c0c5bb", + surface0: "#eaeaea", surface1: "#dcdcdc", surface2: "#c8c8c8", + base: "#ffffff", mantle: "#f6f6f4", crust: "#eaeaea", + }, + status: { + idle: "#8f928b", running: "#b88800", "tool-running": "#0066cc", done: "#3d9400", + error: "#cc0000", waiting: "#0066cc", interrupted: "#cc5500", stale: "#b88800", + }, + icons: CATPPUCCIN_MOCHA.icons, +}; + export const BUILTIN_THEMES: Record = { "catppuccin-mocha": CATPPUCCIN_MOCHA, "catppuccin-latte": CATPPUCCIN_LATTE, @@ -373,6 +407,8 @@ export const BUILTIN_THEMES: Record = { "matrix": MATRIX, "transparent": TRANSPARENT, "shades-of-purple": SHADES_OF_PURPLE, + "tango-adapted": TANGO_ADAPTED, + "tokyo-night-storm": TOKYO_NIGHT_STORM, }; export const DEFAULT_THEME = "catppuccin-mocha"; diff --git a/packages/runtime/test/agent-tracker.test.ts b/packages/runtime/test/agent-tracker.test.ts index 94ca8d1..f95b262 100644 --- a/packages/runtime/test/agent-tracker.test.ts +++ b/packages/runtime/test/agent-tracker.test.ts @@ -266,6 +266,27 @@ describe("AgentTracker", () => { expect(tracker.getState("sess-1")).not.toBeNull(); }); + test("pruneTerminal hard-prunes unseen terminal instances older than 15 min", () => { + const veryOldTs = Date.now() - 16 * 60 * 1000; // past TERMINAL_HARD_PRUNE_MS + tracker.applyEvent(event({ session: "sess-1", status: "done", ts: veryOldTs })); + // NOT marked seen — but old enough that hard cutoff should kick in + + tracker.pruneTerminal(); + + expect(tracker.getState("sess-1")).toBeNull(); + }); + + test("pruneTerminal clears unseen marker when hard-pruning", () => { + const veryOldTs = Date.now() - 16 * 60 * 1000; + tracker.applyEvent(event({ session: "sess-1", status: "done", ts: veryOldTs })); + expect(tracker.isUnseen("sess-1")).toBe(true); + + tracker.pruneTerminal(); + + expect(tracker.isUnseen("sess-1")).toBe(false); + expect(tracker.getUnseen()).not.toContain("sess-1"); + }); + // --- applyPanePresence --- describe("applyPanePresence", () => { From 3d41d80cd72009855d548294dbe5d4d41dddc700 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Sun, 31 May 2026 11:31:13 +0100 Subject: [PATCH 13/13] fix(server): clear new debounce timers on cleanup; guard appearance-watch rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanup() cleared the existing timers but not the two debounce timers this branch added (broadcastDebounceTimer, refreshPaneAgentsTimer), so a pending broadcastStateImmediate() or refreshPaneAgents() could still fire after shutdown. Clear both alongside the others. watchMacSystemAppearance's check() is invoked as `void check()` at all three call sites (file-watch event, safety poll, initial read), so a rejection from the consumer's onChange callback became an unhandled promise rejection. Wrap the check body in try/catch — the watch is best-effort and the next event or safety poll retries. Co-authored-by: Isaac --- packages/runtime/src/server/index.ts | 2 ++ packages/runtime/src/system-theme.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 8f83ba0..a353d8f 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -2271,6 +2271,8 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa function cleanup() { for (const w of allWatchers) w.stop(); if (debounceTimer) clearTimeout(debounceTimer); + if (broadcastDebounceTimer) clearTimeout(broadcastDebounceTimer); + if (refreshPaneAgentsTimer) clearTimeout(refreshPaneAgentsTimer); if (sidebarEnforceTimer) clearTimeout(sidebarEnforceTimer); clearClientResizeSyncTimer(); clearProgrammaticAdjustmentTimer(); diff --git a/packages/runtime/src/system-theme.ts b/packages/runtime/src/system-theme.ts index c893a33..89fc601 100644 --- a/packages/runtime/src/system-theme.ts +++ b/packages/runtime/src/system-theme.ts @@ -85,10 +85,18 @@ export function watchMacSystemAppearance( async function check() { if (stopped) return; - const mode = await readMacSystemAppearance(); - if (mode !== lastMode) { - lastMode = mode; - await onChange(mode); + // All three call sites invoke this as `void check()`, so any rejection + // (most plausibly from the consumer's onChange callback) would surface as + // an unhandled promise rejection. The appearance watch is best-effort — + // swallow so a failing callback can't take down the process. + try { + const mode = await readMacSystemAppearance(); + if (mode !== lastMode) { + lastMode = mode; + await onChange(mode); + } + } catch { + // ignore — next file-watch event or safety poll will retry } }