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/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). 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/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/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/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 7718c8d..a353d8f 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"; @@ -24,6 +24,12 @@ import { } from "./sidebar-coordinator"; import { loadConfig, saveConfig } from "../config"; import type { SessionFilterMode } from "../config"; +import { + readMacSystemAppearance, + themeForSystemMode, + watchMacSystemAppearance, + type SystemAppearanceWatcher, +} from "../system-theme"; import { clampSidebarWidth, } from "./sidebar-width-sync"; @@ -36,6 +42,7 @@ import { SERVER_HOST, LOCAL_CLIENT_HOST, PID_FILE, + ORDERING_FILE, SERVER_IDLE_TIMEOUT_MS, STUCK_RUNNING_TIMEOUT_MS, } from "../shared"; @@ -304,6 +311,12 @@ 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 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). + 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 }); @@ -630,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 }); } } @@ -643,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)); @@ -657,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 }) => { @@ -697,7 +722,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), }; }); @@ -726,25 +754,78 @@ 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; + + // 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); - 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 @@ -844,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 @@ -1075,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; @@ -1160,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; @@ -1210,8 +1306,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 @@ -1447,7 +1545,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; @@ -1460,7 +1558,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; @@ -2059,7 +2162,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": @@ -2158,13 +2270,15 @@ 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 (broadcastDebounceTimer) clearTimeout(broadcastDebounceTimer); + if (refreshPaneAgentsTimer) clearTimeout(refreshPaneAgentsTimer); if (sidebarEnforceTimer) clearTimeout(sidebarEnforceTimer); clearClientResizeSyncTimer(); clearProgrammaticAdjustmentTimer(); if (portPollTimer) clearInterval(portPollTimer); if (paneScanTimer) clearInterval(paneScanTimer); + if (systemThemeWatcher) systemThemeWatcher.stop(); for (const timer of pendingHighlightResets.values()) clearTimeout(timer); pendingHighlightResets.clear(); for (const watcher of gitHeadWatchers.values()) watcher.close(); @@ -2178,7 +2292,27 @@ 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)); @@ -2303,7 +2437,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 }); @@ -2317,7 +2451,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 }); @@ -2606,6 +2740,37 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa startIdleTimerIfNeeded("server booted without clients"); + // --- macOS system-appearance follower ----------------------------------- + // 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(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 + // 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(observed, dark, light); + if (desired === currentTheme) return; + log("system-theme", "switching", { mode: observed, from: currentTheme, to: desired }); + currentTheme = desired; + broadcastState(); + } + + systemThemeWatcher = watchMacSystemAppearance((mode) => { void syncSystemTheme(mode); }); + log("system-theme", "watcher started", { darkTheme, lightTheme }); + } + // ------------------------------------------------------------------------ + process.on("SIGINT", () => { cleanup(); process.exit(0); }); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); diff --git a/packages/runtime/src/shared.ts b/packages/runtime/src/shared.ts index 3ae05ac..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,7 +57,14 @@ 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; +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 +// 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 { @@ -77,7 +89,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; } diff --git a/packages/runtime/src/system-theme.ts b/packages/runtime/src/system-theme.ts new file mode 100644 index 0000000..89fc601 --- /dev/null +++ b/packages/runtime/src/system-theme.ts @@ -0,0 +1,123 @@ +/** + * macOS system-appearance helpers. + * + * On macOS, the global "Appearance" preference (System Settings → Appearance) + * 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. + * - `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"; + +/** + * 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; +} + +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; + // 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 + } + } + + 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/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", () => { 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..126ef62 --- /dev/null +++ b/packages/runtime/test/system-theme.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from "bun:test"; + +import { + readMacSystemAppearance, + themeForSystemMode, + watchMacSystemAppearance, +} 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 }); + } + }); +}); + +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); + }); +});