mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint
Four responsiveness/hardening fixes surfaced by rapid /skin switching: config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only when MCP-relevant config changed (cosmetic /skin writes cost seconds of reconnects before); reload.mcp runs on the RPC pool serialized by a lock (inline it froze the stdio reader for the duration of a flapping server's retry loop — config.set/complete.slash sat unread and the TUI appeared dead); theme swaps schedule one full repaint (incremental diffs after a recolor tear — stale cells keep the old palette, read as "shadows"); and OSC-11 pure-black answers are distrusted universally (unset-default fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead PTY made every render write throw once a second forever; exit after 5 consecutive dead-stream errors.
This commit is contained in:
parent
c0763bd13a
commit
296303302d
8 changed files with 165 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ConfigMtimeResponse>(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<ReloadMcpResponse>(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<ReloadMcpResponse>(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)
|
||||
|
|
|
|||
|
|
@ -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 ===`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue