diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7941481b0bed..18ced36f1833 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3,6 +3,7 @@ import concurrent.futures import contextlib import contextvars import copy +import hashlib import inspect import json import logging @@ -226,6 +227,13 @@ _LONG_HANDLERS = frozenset( "pet.thumb", "learning.frames", "plugins.manage", + # reload.mcp shuts down and rediscovers every MCP server — with a + # flapping server (retry loops, connect timeouts up to 120s) that can + # block for minutes. Inline it froze the reader thread: config.set, + # complete.slash, prompt.submit all sat unread and the TUI appeared + # dead after a few skin switches. The handler serializes concurrent + # reloads via _mcp_reload_lock. + "reload.mcp", "process.list", "projects.discover_repos", "projects.record_repos", @@ -12674,11 +12682,24 @@ def _(rid, params: dict) -> dict: if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: - return _ok( - rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0} - ) + mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0 except Exception: return _ok(rid, {"mtime": 0}) + # Revision hash of the MCP-relevant config sections. The TUI's + # config-change poller uses it to reload MCP servers only when their + # config actually changed — a /skin or /statusbar write bumps mtime + # but must not cost a multi-second MCP reconnect. + try: + cfg = _load_cfg() + rev_src = json.dumps( + {"mcp": cfg.get("mcp"), "tools": cfg.get("tools")}, + sort_keys=True, + default=str, + ) + mcp_rev = hashlib.sha1(rev_src.encode()).hexdigest()[:12] + except Exception: + mcp_rev = "" + return _ok(rid, {"mtime": mtime, "mcp_rev": mcp_rev}) return _err(rid, 4002, f"unknown config key: {key}") @@ -12852,6 +12873,14 @@ def _(rid, params: dict) -> dict: return _err(rid, 5010, str(e)) +# reload.mcp runs on the RPC pool (see _LONG_HANDLERS) so a slow/flapping MCP +# server can't freeze the reader thread. Serialize reloads: overlapping +# shutdown+discover pairs from stacked config-change polls would interleave +# and leave the registry half-built. Piggyback rather than queue — a reload +# that arrives while one is running would just redo identical work. +_mcp_reload_lock = threading.Lock() + + @method("reload.mcp") def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) @@ -12906,8 +12935,18 @@ def _(rid, params: dict) -> dict: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools - shutdown_mcp_servers() - discover_mcp_tools() + if not _mcp_reload_lock.acquire(blocking=False): + # A reload is already in flight; wait for it to finish and reuse + # its result instead of tearing the freshly-built registry down. + with _mcp_reload_lock: + pass + return _ok(rid, {"status": "reloaded", "coalesced": True}) + + try: + shutdown_mcp_servers() + discover_mcp_tools() + finally: + _mcp_reload_lock.release() if session: agent = session["agent"] # Rebuild the cached agent's tool snapshot so the current session diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index f8746fadb9f7..7343d5d55e0b 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' -import { onTerminalBackground } from '@hermes/ink' +import { forceRedraw, onTerminalBackground } from '@hermes/ink' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -50,9 +50,42 @@ const themeForSkin = (s: GatewaySkin) => { // Patch the live theme AND persist it for the next launch's first frame // (flash-free boot — see lib/themeBoot.ts). +// +// The force-redraw is load-bearing: a theme swap recolors EVERYTHING, but the +// renderer's diff/blit cache treats layout-unchanged regions as reusable, so +// incremental repaints after a swap can tear — stale cells keep the previous +// palette (observed live: gold headers from the boot theme composited with +// slate chrome, dark status fills surviving on a light terminal, half- +// overwritten glyphs reading as "shadows"). One full clear+repaint after the +// new theme has rendered guarantees a coherent frame. Deferred ~2 frames so +// React + Ink flush the recolored tree first; skipping identical themes keeps +// the no-op resolution path (boot cache confirmed by detection) paint-free. +let lastCommittedTheme: Theme | null = null + const commitTheme = (theme: Theme) => { + const changed = lastCommittedTheme !== null && !themesEqual(lastCommittedTheme, theme) + + lastCommittedTheme = theme patchUiState({ theme }) writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND) + + if (changed) { + setTimeout(() => forceRedraw(process.stdout), 40).unref?.() + } +} + +const themesEqual = (a: Theme, b: Theme) => { + if (a === b) { + return true + } + + for (const key of Object.keys(a.color) as (keyof Theme['color'])[]) { + if (a.color[key] !== b.color[key]) { + return false + } + } + + return a.brand.name === b.brand.name && a.brand.prompt === b.brand.prompt && a.bannerLogo === b.bannerLogo && a.bannerHero === b.bannerHero } const applySkin = (s: GatewaySkin) => { @@ -122,13 +155,15 @@ export function syncThemeToTerminalBackground(): void { let resolved = false onTerminalBackground(hex => { - // xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the - // editor theme sets no explicit terminal background — the renderer's - // unset DEFAULT, not the painted color (observed: pure black reported on - // a white terminal). A real vscode dark theme reports its actual surface - // (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no - // signal: skip the cache and fall through to the OS-appearance inference. - if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') { + // Exactly-#000000 is the "unset default" fingerprint, not a measurement: + // xterm.js reports it when the editor theme sets no terminal background + // (observed: pure black reported on a white Cursor terminal), and tmux + // answers OSC 11 with its own black fallback regardless of the outer + // terminal — and tmux also strips TERM_PROGRAM, so no host allow-list + // can catch it. Real dark themes report their actual surface (#1e1e1e, + // #282828, …). Distrusting pure black universally is safe: on a truly + // pure-black terminal the fall-through detection lands on dark anyway. + if (hex === '#000000') { return } diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 074e5b739197..af7eb8ab5ec0 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -245,6 +245,7 @@ export function useConfigSync({ sid }: UseConfigSyncOptions) { const mtimeRef = useRef(0) + const mcpRevRef = useRef('') useEffect(() => { if (!sid) { @@ -270,10 +271,12 @@ export function useConfigSync({ const id = setInterval(() => { quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { const next = Number(r?.mtime ?? 0) + const nextMcpRev = String(r?.mcp_rev ?? '') if (!mtimeRef.current) { if (next) { mtimeRef.current = next + mcpRevRef.current = nextMcpRev } return @@ -285,9 +288,21 @@ export function useConfigSync({ mtimeRef.current = next - quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( - r => r && turnController.pushActivity('MCP reloaded after config change') - ) + // Reload MCP only when the MCP-relevant config actually changed. + // Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly; + // reconnecting every MCP server for those costs seconds and made + // skin switching feel glacial. Older gateways don't send mcp_rev — + // fall back to reload-on-any-change there. + const mcpChanged = !nextMcpRev || nextMcpRev !== mcpRevRef.current + + mcpRevRef.current = nextMcpRev + + if (mcpChanged) { + quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( + r => r && turnController.pushActivity('MCP reloaded after config change') + ) + } + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) }) }, MTIME_POLL_MS) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index f25be8091bdd..6f856cf57a9a 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -55,6 +55,8 @@ gw.start() const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) => `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n` +let consecutiveDeadStreamErrors = 0 + setupGracefulExit({ cleanups: [ () => { @@ -67,7 +69,31 @@ setupGracefulExit({ const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) recordParentLifecycle(`${scope}: ${message.split('\n')[0]?.slice(0, 400) ?? ''}`) - process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + + // A dead PTY (terminal tab closed, SSH dropped without SIGHUP) turns every + // stdout/stderr write into EIO/EPIPE. Swallowing those here made the parent + // a zombie: Ink's render loop throws once a second, each throw lands back + // in this handler, and the crash log fills with `write EIO` forever while + // the gateway child keeps running. Bail out for real after a few in a row. + const code = (err as NodeJS.ErrnoException)?.code + + if (code === 'EIO' || code === 'EPIPE') { + if (++consecutiveDeadStreamErrors >= 5) { + recordParentLifecycle(`dead output stream (${code} x${consecutiveDeadStreamErrors}) → exiting`) + void gw.kill('dead-output-stream') + process.exit(1) + } + + return + } + + consecutiveDeadStreamErrors = 0 + + try { + process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + } catch { + // stderr may be the dead stream itself. + } }, onSignal: signal => { // The next line in the crash log is the child's `=== SIGTERM received ===` diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 521161840618..a4bbe04a8971 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -128,6 +128,9 @@ export interface ConfigFullResponse { } export interface ConfigMtimeResponse { + /** Revision hash of MCP-relevant config sections; reload MCP only when it + * changes (cosmetic writes like /skin must not trigger reconnects). */ + mcp_rev?: string mtime?: number } diff --git a/ui-tui/src/lib/color.ts b/ui-tui/src/lib/color.ts index 3c95076298c7..3d7bd080844b 100644 --- a/ui-tui/src/lib/color.ts +++ b/ui-tui/src/lib/color.ts @@ -265,6 +265,23 @@ export function retone(color: string, lightness: number, minSaturation = 0): str return toHex(fromHsl(h, Math.max(s, minSaturation), lightness)) } +/** Multiply HSL saturation by `factor` (clamped to 1), hue/lightness fixed. */ +export function boostSaturation(color: string, factor: number): string { + const rgb = parseColor(color) + + if (!rgb) { + return color + } + + const [h, s, l] = toHsl(rgb) + + if (s === 0) { + return color + } + + return toHex(fromHsl(h, Math.min(1, s * factor), l)) +} + /** * Chainable form for multi-step derivations, e.g. * `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`. diff --git a/ui-tui/src/lib/themeBoot.ts b/ui-tui/src/lib/themeBoot.ts index 0471eaa2008e..6062ac2c103d 100644 --- a/ui-tui/src/lib/themeBoot.ts +++ b/ui-tui/src/lib/themeBoot.ts @@ -111,7 +111,16 @@ export function writeBootTheme(theme: Theme, background?: string): void { */ const boot = readBootTheme() -if (boot?.background && !process.env.HERMES_TUI_BACKGROUND && !process.env.HERMES_TUI_THEME && !process.env.HERMES_TUI_LIGHT) { +if ( + boot?.background && + // Never seed the untrusted "unset default" fingerprint — a cache written + // before the distrust rule existed must not poison this session's + // detection (it would also suppress the macOS-appearance fallback). + boot.background.toLowerCase() !== '#000000' && + !process.env.HERMES_TUI_BACKGROUND && + !process.env.HERMES_TUI_THEME && + !process.env.HERMES_TUI_LIGHT +) { process.env.HERMES_TUI_BACKGROUND = boot.background } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 690b4aa84263..cb29f93f7e40 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -541,7 +541,9 @@ export function deriveTones(seeds: { const { accent, bg, primary, text } = seeds const isLight = (relativeLuminance(bg) ?? 0) > 0.5 const inkBlend = mix(primary, text, 0.27) - const surface = mix(bg, desaturate(accent, 0.35), isLight ? 0.045 : 0.09) + // Fill tint keeps most of the accent's chroma — a heavier desaturate here + // read as washed-out ("a little too desat") next to authored fills. + const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09) return { muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16),