diff --git a/cli.py b/cli.py index 1df9ed2ce..9f3776ab8 100644 --- a/cli.py +++ b/cli.py @@ -7447,6 +7447,20 @@ class HermesCLI: # Register atexit cleanup so resources are freed even on unexpected exit atexit.register(_run_cleanup) + # Register signal handlers for graceful shutdown on SSH disconnect / SIGTERM + def _signal_handler(signum, frame): + """Handle SIGHUP/SIGTERM by triggering graceful cleanup.""" + logger.debug("Received signal %s, triggering graceful shutdown", signum) + raise KeyboardInterrupt() + + try: + import signal as _signal + _signal.signal(_signal.SIGTERM, _signal_handler) + if hasattr(_signal, 'SIGHUP'): + _signal.signal(_signal.SIGHUP, _signal_handler) + except Exception: + pass # Signal handlers may fail in restricted environments + # Install a custom asyncio exception handler that suppresses the # "Event loop is closed" RuntimeError from httpx transport cleanup. # This is defense-in-depth — the primary fix is neuter_async_httpx_del @@ -7470,7 +7484,7 @@ class HermesCLI: except Exception: pass app.run() - except (EOFError, KeyboardInterrupt): + except (EOFError, KeyboardInterrupt, BrokenPipeError): pass finally: self._should_exit = True @@ -7509,6 +7523,23 @@ class HermesCLI: self._session_db.end_session(self.agent.session_id, "cli_close") except (Exception, KeyboardInterrupt) as e: logger.debug("Could not close session in DB: %s", e) + # Plugin hook: on_session_end — safety net for interrupted exits. + # run_conversation() already fires this per-turn on normal completion, + # so only fire here if the agent was mid-turn (_agent_running) when + # the exit occurred, meaning run_conversation's hook didn't fire. + if self.agent and getattr(self, '_agent_running', False): + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_end", + session_id=self.agent.session_id, + completed=False, + interrupted=True, + model=getattr(self.agent, 'model', None), + platform=getattr(self.agent, 'platform', None) or "cli", + ) + except Exception: + pass _run_cleanup() self._print_exit_summary()