feat(plugins): show on_session_finalize return messages to the user

A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:

- CLI /new: printed before the new session; CLI quit: printed above the
  exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
  adapters are still connected

on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
This commit is contained in:
teknium1 authored and Teknium committed 2026-10-08 04:11:24 -07:00
1 parent 3470e3ee2f
commit dacb06ea93
17 files changed
+352 -52

No files matched your search

@@ -3329,6 +3329,7 @@ export interface SessionCloseParams {
}
export interface SessionCloseResult {
closed: boolean
messages?: string[]
}
export interface SessionBranchParams {
session_id: string
@@ -29510,6 +29510,13 @@
"closed": {
"title": "Closed",
"type": "boolean"
},
"messages": {
"items": {
"type": "string"
},
"title": "Messages",
"type": "array"
}
},
"required": [
+4 -1
View File
@@ -409,6 +409,7 @@ _cleanup_all_browsers = _lazy_shim("tools.browser_tool_lifecycle", "_emergency_c
_cleanup_done = False # _run_cleanup runs exactly once
_cleanup_in_progress = False
_session_end_messages: list[str] = [] # plugin on_session_finalize text awaiting the exit summary
_cli_wake_owner = None
# One-shot finalization runs before process cleanup (plugins see the boundary while the
# agent is attached); atexit cleanup must not finalize those sessions again.
@@ -520,7 +521,9 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
if notify_session_finalize:
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
if _should_emit_cleanup_session_finalize(cleanup_session_id):
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown")
# Printed by _print_exit_summary, which clears the screen first.
_session_end_messages.extend(
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown"))
try:
_shutdown_agent_memory_provider(_active_agent_ref)
except Exception as e:
+7 -27
View File
@@ -28,6 +28,7 @@ from gateway.restart import (
effective_stop_drain_timeout, effective_stop_watchdog_delay, resolve_cron_drain_budget
)
from gateway.run_common import _UNSET
from gateway.run_shutdown_session_end import GatewaySessionEndMixin
from gateway.shutdown_watchdog import arm_shutdown_watchdog, resolve_shutdown_watchdog_delay
# Log-record parity with the origin module.
@@ -168,7 +169,7 @@ def _effective_watchdog_leash(runner: object) -> float:
return effective_stop_watchdog_delay(runner, resolve_shutdown_watchdog_delay(effective_stop_drain_timeout(runner)))
class GatewayShutdownMixin:
class GatewayShutdownMixin(GatewaySessionEndMixin):
"""Stop/drain/restart, scale-to-zero and active-work accounting methods for GatewayRunner."""
@dataclasses.dataclass
@@ -1173,10 +1174,14 @@ class GatewayShutdownMixin:
self._flush_agent_transcript_at_shutdown(agent)
# Off-loop + bounded: plugin on_session_finalize hooks can do arbitrary synchronous work
# (e.g. a full-session trace export) — same hang class as the memory provider below.
await self._finalize_session_off_loop(
messages = await self._finalize_session_off_loop(
session_id=getattr(agent, "session_id", None), platform="gateway", reason="shutdown",
session_key=session_key,
)
try: # adapters are still connected here (teardown runs after this loop)
await self._deliver_session_end_messages(messages, session_key=session_key)
except Exception:
logger.debug("Session-end plugin message delivery failed for %s", session_key, exc_info=True)
# Off-loop + bounded: a wedged memory provider here used to hang the whole shutdown so
# SIGTERM never completed.
await self._cleanup_agent_resources_off_loop(agent, context="shutdown finalize", session_key=session_key)
@@ -1224,31 +1229,6 @@ class GatewayShutdownMixin:
tasks = self._deferred_agent_cleanup_tasks = set()
self._track_task_in(tasks, asyncio.create_task(_cleanup_when_done()))
async def _finalize_session_off_loop(
self, *, session_id: Any, platform: str, reason: str, session_key: Optional[str] = None, **extra: Any,
) -> None:
"""Run hermes_cli.lifecycle.finalize_session off-loop, bounded; on timeout the worker is left alone.
``session_key`` lets an unscoped caller (shutdown) enter the owning profile's scope: plugin
``on_session_finalize`` observers and the Relay coordinator (``current_profile_key``) resolve
profile state at call time."""
def _call() -> None:
from hermes_cli.lifecycle import finalize_session
finalize_session(session_id=session_id, platform=platform, reason=reason, **extra)
try:
await asyncio.wait_for(
self._run_housekeeping_in_executor(self._run_release_in_profile_scope, _call, (), session_key),
timeout=self._FINALIZE_TIMEOUT_S,
)
except asyncio.TimeoutError:
logger.warning(
"Session finalize hooks (%s, reason=%s) exceeded %ss; proceeding without blocking the event loop "
"(the worker thread is left to finish on its own).", session_id, reason, self._FINALIZE_TIMEOUT_S,
)
except Exception as finalize_exc:
logger.debug("Session finalize hooks (%s, reason=%s) failed: %s", session_id, reason, finalize_exc)
async def _cleanup_agent_resources_off_loop(
self, agent: Any, *, context: str = "", session_key: Optional[str] = None,
) -> None:
+64
View File
@@ -0,0 +1,64 @@
"""Session finalize dispatch and delivery of plugin ``on_session_finalize`` messages for GatewayRunner.
Split out of ``gateway/run_shutdown.py``; bound onto ``GatewayRunner`` via ``GatewayShutdownMixin``.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, List, Optional
logger = logging.getLogger("gateway.run")
class GatewaySessionEndMixin:
"""Bounded off-loop ``finalize_session`` plus out-of-band delivery of what plugins return."""
async def _deliver_session_end_messages(
self, messages: List[str], *, source: Any = None, session_key: Optional[str] = None,
) -> None:
"""Send plugin ``on_session_finalize`` text to the chat that owns the session — a plain adapter send,
never an agent turn. No resolvable chat (or no adapter) → logged and dropped."""
if not messages:
return
if source is None and session_key:
target = await self._shutdown_notification_target(session_key)
source = target[0] if target else None
adapter = self._delivery_adapter_for(source) if source is not None else None
if adapter is None:
logger.info("Session-end plugin message(s) dropped: no chat for session %s", session_key)
return
metadata = self._thread_metadata_for_source(source)
for message in messages:
await self._send_notice_logged(
adapter, str(source.chat_id), message, source.platform.value if source.platform else "",
"Failed to send session-end plugin message to %s:%s: %s", metadata=metadata)
async def _finalize_session_off_loop(
self, *, session_id: Any, platform: str, reason: str, session_key: Optional[str] = None, **extra: Any,
) -> List[str]:
"""Run hermes_cli.lifecycle.finalize_session off-loop, bounded; on timeout the worker is left alone.
``session_key`` lets an unscoped caller (shutdown) enter the owning profile's scope: plugin
``on_session_finalize`` observers and the Relay coordinator (``current_profile_key``) resolve
profile state at call time. Returns the plugins' user-facing messages (empty on timeout/error)."""
messages: List[str] = []
def _call() -> None:
from hermes_cli.lifecycle import finalize_session, session_end_messages
messages.extend(session_end_messages(
finalize_session(session_id=session_id, platform=platform, reason=reason, **extra)))
try:
await asyncio.wait_for(
self._run_housekeeping_in_executor(self._run_release_in_profile_scope, _call, (), session_key),
timeout=self._FINALIZE_TIMEOUT_S,
)
except asyncio.TimeoutError:
logger.warning(
"Session finalize hooks (%s, reason=%s) exceeded %ss; proceeding without blocking the event loop "
"(the worker thread is left to finish on its own).", session_id, reason, self._FINALIZE_TIMEOUT_S,
)
except Exception:
logger.debug("Session finalize hooks (%s, reason=%s) failed", session_id, reason, exc_info=True)
return list(messages)
+2 -1
View File
@@ -145,9 +145,10 @@ class GatewaySessionCommandsMixin:
arbitrarily), then session:end and session:reset."""
platform_value = source.platform.value if source.platform else ""
with contextlib.suppress(Exception):
await self._finalize_session_off_loop(
messages = await self._finalize_session_off_loop(
session_id=old_sid, platform=platform_value, reason="new_session",
old_session_id=old_sid, new_session_id=new_sid)
await self._deliver_session_end_messages(messages, source=source, session_key=session_key)
hook_payload = {"platform": platform_value, "user_id": source.user_id, "session_key": session_key}
await self.hooks.emit("session:end", dict(hook_payload))
await self.hooks.emit("session:reset", dict(hook_payload))
+9 -1
View File
@@ -430,7 +430,10 @@ class CLISessionMixin:
"platform": getattr(self, "platform", None) or "cli",
"reason": "new_session" if event_type == "on_session_reset" else "session_boundary"}
if event_type == "on_session_finalize":
finalize_session(**context)
from cli import _cli_visible_print
from hermes_cli.lifecycle import session_end_messages
for message in session_end_messages(finalize_session(**context)):
_cli_visible_print(message)
else:
invoke_hook(event_type, **context)
@@ -1103,6 +1106,11 @@ class CLISessionMixin:
# Honors NO_COLOR/dumb terminals by skipping silently when there's no real console.
self._clear_terminal_on_exit()
print()
import cli as _cli_module
# Plugin on_session_finalize messages from _run_cleanup: printed after the clear so they survive.
for message in _cli_module._session_end_messages:
print(message)
_cli_module._session_end_messages.clear()
msg_count = len(self.conversation_history)
if not msg_count:
try:
+9 -4
View File
@@ -168,10 +168,12 @@ def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
return session_id not in _cli()._single_query_finalize_attempted_session_ids
def _notify_session_finalize(*, session_id: str | None, platform: str = "cli", reason: str = "shutdown") -> None:
def _notify_session_finalize(*, session_id: str | None, platform: str = "cli", reason: str = "shutdown") -> list[str]:
"""Fire on_session_finalize; return the plugin messages meant for the user (never raises)."""
with suppress(Exception):
from hermes_cli.lifecycle import finalize_session
finalize_session(session_id=session_id, platform=platform, reason=reason)
from hermes_cli.lifecycle import finalize_session, session_end_messages
return session_end_messages(finalize_session(session_id=session_id, platform=platform, reason=reason))
return []
def _oneshot_agent_and_session(cli):
@@ -224,7 +226,10 @@ def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> N
return
try:
_notify_session_finalize(session_id=session_id, platform=getattr(agent, "platform", None) or "cli", reason=reason)
# stderr: one-shot stdout is the answer scripts parse.
for message in _notify_session_finalize(
session_id=session_id, platform=getattr(agent, "platform", None) or "cli", reason=reason):
print(message, file=sys.stderr, flush=True)
finally:
_cli()._single_query_finalize_attempted_session_ids.add(session_id)
+13
View File
@@ -53,6 +53,19 @@ def has_hook(hook_name: str) -> bool:
return plugins.has_hook(hook_name)
def session_end_messages(results: Any) -> List[str]:
"""User-facing text from ``on_session_finalize`` results: a non-empty ``str`` or ``{"message": str}``.
Surfaces show these out-of-band (CLI print, TUI system line, gateway chat send) — never as a model
turn. ``on_session_end`` is per-turn and stays observer-only."""
messages: List[str] = []
for result in results or ():
text = result.get("message") if isinstance(result, dict) else result
if isinstance(text, str) and text.strip():
messages.append(text.strip())
return messages
def finalize_session(**kwargs: Any) -> List[Any]:
"""Notify observers and hard-close one core-owned Relay conversation."""
_observe("on_session_finalize", **kwargs)
@@ -0,0 +1,183 @@
"""Plugin ``on_session_finalize`` return values reach the user out-of-band on every surface.
A hook returns ``str`` or ``{"message": str}``; the CLI prints it, the TUI gets it on the
``session.close`` result, the messaging gateway sends it to the owning chat via the adapter.
No surface starts an agent turn with it. ``on_session_end`` (per turn) stays observer-only.
"""
from __future__ import annotations
import asyncio
import threading
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import cli as cli_mod # module-level, like sibling CLI tests: importing cli runs bootstrap
from hermes_cli import plugins
from hermes_cli.plugins import PluginManager
DIGEST = "Session digest: 3 files touched"
@pytest.fixture
def finalize_hook(monkeypatch):
"""A real PluginManager whose only hook is an on_session_finalize returning a message."""
calls: list[dict] = []
def _hook(**kwargs):
calls.append(kwargs)
return {"message": DIGEST}
mgr = PluginManager()
mgr._discovered = True
mgr._hooks["on_session_finalize"] = [_hook]
monkeypatch.setattr(plugins, "get_plugin_manager", lambda: mgr)
return calls
def test_session_end_messages_accepts_str_and_message_dict_only():
from hermes_cli.lifecycle import session_end_messages
assert session_end_messages([" hi ", {"message": "there"}, {"context": "x"}, {"message": 3}, "", None, 7]) == [
"hi", "there"]
# ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
def test_cli_new_prints_finalize_message(finalize_hook, capsys):
cli = cli_mod.HermesCLI()
cli.agent = MagicMock()
cli.agent.session_id = "old-session"
cli.new_session(silent=True)
assert finalize_hook and finalize_hook[0]["session_id"] == "old-session"
assert DIGEST in capsys.readouterr().out
def test_cli_exit_prints_finalize_message_after_screen_clear(finalize_hook, capsys, monkeypatch):
agent = MagicMock()
agent.session_id = "exit-session"
monkeypatch.setattr(cli_mod, "_active_agent_ref", agent)
monkeypatch.setattr(cli_mod, "_cleanup_done", False)
monkeypatch.setattr(cli_mod, "_session_end_messages", [])
cli_mod._run_cleanup()
obj = cli_mod.HermesCLI()
obj.conversation_history = []
obj._print_exit_summary(clear_screen=False)
assert DIGEST in capsys.readouterr().out
assert cli_mod._session_end_messages == []
def test_one_shot_prints_finalize_message_to_stderr(finalize_hook, capsys, monkeypatch):
monkeypatch.setattr(cli_mod, "_single_query_finalize_attempted_session_ids", set())
agent = SimpleNamespace(session_id="oneshot-session", platform="cli")
cli_mod._notify_single_query_session_finalize(SimpleNamespace(agent=agent, session_id="oneshot-session"))
out = capsys.readouterr()
assert DIGEST in out.err
assert DIGEST not in out.out # stdout is the answer scripts parse
# ── TUI / Desktop gateway ────────────────────────────────────────────────────────────────────────
def test_tui_session_close_returns_finalize_message(finalize_hook, monkeypatch):
from tui_gateway import server
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
monkeypatch.setattr(server, "_TURN_SETTLE_BEFORE_CLOSE_SECONDS", 0.0)
server._sessions["close-sid"] = {
"agent": None, "session_key": "close-key", "history": [], "history_lock": threading.Lock(),
"running": False, "slash_worker": None}
reply = server.handle_request(
{"id": "1", "method": "session.close", "params": {"session_id": "close-sid"}})
assert reply["result"] == {"closed": True, "messages": [DIGEST]}
assert finalize_hook[0]["session_id"] == "close-key"
# ── messaging gateway ────────────────────────────────────────────────────────────────────────────
def _gateway_runner():
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
source = SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="t", chat_type="dm")
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")})
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides, runner._session_reasoning_overrides = {}, {}
runner._pending_model_notes, runner._background_tasks = {}, set()
key = build_session_key(source)
entry = SessionEntry(session_key=key, session_id="sess-1", created_at=datetime.now(),
updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm", origin=source)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = entry
runner.session_store.reset_session.return_value = entry
runner.session_store._entries = {key: entry}
runner.session_store._generate_session_key.return_value = key
runner._running_agents, runner._pending_messages, runner._pending_approvals = {}, {}, {}
runner._session_db = None
runner._agent_cache_lock = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
return runner, adapter, source, key
def _sent_texts(adapter) -> list[str]:
return [c.args[1] for c in adapter.send.await_args_list]
@pytest.mark.asyncio
async def test_gateway_new_sends_finalize_message_to_owning_chat(finalize_hook):
from gateway.platforms.event import MessageEvent
runner, adapter, source, _key = _gateway_runner()
reply = await runner._handle_reset_command(MessageEvent(text="/new", source=source, message_id="m1"))
assert finalize_hook[0]["session_id"] == "sess-1"
assert DIGEST in _sent_texts(adapter)
assert adapter.send.await_args_list[_sent_texts(adapter).index(DIGEST)].args[0] == "c1"
assert DIGEST not in str(reply) # a separate notice, not folded into the /new banner
@pytest.mark.asyncio
async def test_gateway_shutdown_sends_finalize_message_before_teardown(finalize_hook):
runner, adapter, _source, key = _gateway_runner()
runner._cleanup_agent_resources_off_loop = AsyncMock()
runner._flush_agent_transcript_at_shutdown = lambda agent: None
await runner._finalize_shutdown_agents({key: SimpleNamespace(session_id="sess-1")})
assert DIGEST in _sent_texts(adapter)
@pytest.mark.asyncio
async def test_gateway_finalize_without_message_sends_nothing(monkeypatch):
mgr = PluginManager()
mgr._discovered = True
mgr._hooks["on_session_finalize"] = [lambda **_: None]
monkeypatch.setattr(plugins, "get_plugin_manager", lambda: mgr)
runner, adapter, _source, key = _gateway_runner()
runner._cleanup_agent_resources_off_loop = AsyncMock()
runner._flush_agent_transcript_at_shutdown = lambda agent: None
await runner._finalize_shutdown_agents({key: SimpleNamespace(session_id="sess-1")})
adapter.send.assert_not_awaited()
+2
View File
@@ -392,6 +392,8 @@ class SessionCloseParams(SessionParams):
class SessionCloseResult(Result):
closed: bool # False when the runtime id was already gone
# Plugin ``on_session_finalize`` text for the user; shown out-of-band, never a model turn.
messages: list[str] = Field(default_factory=list)
method("session.close", params=SessionCloseParams, result=SessionCloseResult,
+1 -1
View File
@@ -2203,7 +2203,7 @@ def _(rid, params: dict, session: dict) -> dict:
def _(rid, params: dict) -> dict:
with _session_resume_lock: # lock only the ownership claim; finalization must not block resumes
session = _pop_session_by_id(params.get("session_id", ""))
return _ok(rid, {"closed": _teardown_popped_session(session, end_reason="tui_close")})
return _ok(rid, {"closed": _teardown_popped_session(session), "messages": (session or {}).get("_end_msgs", [])})
@_session_method("session.branch", live=True)
+8 -7
View File
@@ -46,14 +46,14 @@ def _start_session_work(target, *, name: str, session: dict | None = None):
raise
def _notify_session_boundary(event_type: str, session_id: str | None, platform: str | None = None) -> None:
"""Fire session lifecycle hooks with CLI parity."""
def _notify_session_boundary(event_type: str, session_id: str | None, platform: str | None = None) -> list[str]:
"""Fire session lifecycle hooks with CLI parity; returns plugin ``on_session_finalize`` user messages."""
with contextlib.suppress(Exception):
from hermes_cli.lifecycle import finalize_session, invoke_hook
from hermes_cli.lifecycle import finalize_session, invoke_hook, session_end_messages
if event_type == "on_session_finalize":
finalize_session(session_id=session_id, platform=_resolve_agent_platform(platform))
else:
invoke_hook(event_type, session_id=session_id, platform=_resolve_agent_platform(platform))
return session_end_messages(finalize_session(session_id=session_id, platform=_resolve_agent_platform(platform)))
invoke_hook(event_type, session_id=session_id, platform=_resolve_agent_platform(platform))
return []
_SESSION_OWNERSHIP_UNAVAILABLE = "Hermes could not safely reserve this session. Try again."
@@ -395,7 +395,8 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
session_key = session.get("session_key")
session_id = getattr(agent, "session_id", None) or session_key
_notify_session_boundary("on_session_finalize", session_id, _session_source(session))
# Returned to the client by ``session.close`` (the TUI shows them after its /new reset); reaper paths have no client.
session["_end_msgs"] = _notify_session_boundary("on_session_finalize", session_id, _session_source(session))
# End the state.db row so it doesn't linger as a ghost in /resume. Use session_id (agent.session_id), not
# session_key: after compression the key may be the stale ended parent while session_id is the live continuation.
# Fix for #20001.
+1 -1
View File
@@ -548,7 +548,7 @@ export interface SlashHandlerContext {
setCatalog: StateSetter<null | SlashCatalog>
}
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
closeSession: (targetSid?: null | string, deferMessages?: boolean) => Promise<unknown>
die: () => void
dieWithCode: (code: number) => void
guardBusySessionSwitch: (what?: string) => boolean
+16 -7
View File
@@ -146,10 +146,19 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
sys
} = opts
// Plugin on_session_finalize text comes back on the close result and is shown as system lines (never a
// model turn). `deferMessages`: the caller resets the transcript next and shows them itself afterwards.
const closeSession = useCallback(
(targetSid?: null | string) =>
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
async (targetSid?: null | string, deferMessages = false) => {
const closed = targetSid ? await rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : null
if (!deferMessages) {
closed?.messages?.forEach(message => sys(message))
}
return closed
},
[rpc, sys]
)
const cancelResumeScrollRef = useRef<null | (() => void)>(null)
@@ -207,10 +216,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
}
const previousSid = getUiState().sid
if (!keepCurrent) {
await closeSession(previousSid)
}
const closed = keepCurrent ? null : await closeSession(previousSid, true)
const r = await rpc<SessionCreateResponse>('session.create', {
cols: colsRef.current,
@@ -257,6 +263,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
sys(msg)
}
// After the reset above, so the closed session's plugin messages stay visible.
closed?.messages?.forEach(message => sys(message))
if (requestedTitle) {
rpc<SessionTitleResponse>('session.title', {
session_id: r.session_id,
+2
View File
@@ -298,6 +298,8 @@ export interface SessionBranchResponse {
export interface SessionCloseResponse {
closed?: boolean
/** Plugin `on_session_finalize` text for the user (shown as system lines, never a model turn). */
messages?: string[]
ok?: boolean
}
+23 -2
View File
@@ -966,7 +966,7 @@ def my_callback(session_id: str, completed: bool, interrupted: bool,
1. **`agent/turn_finalizer.py`** — at the end of every `run_conversation()` call (`agent/conversation_loop.py`), after all cleanup. Always fires, even if the turn errored.
2. **`cli.py`** — in the CLI's atexit handler, but **only** if the agent was mid-turn (`_agent_running=True`) when the exit occurred. This catches Ctrl+C and `/exit` during processing. In this case, `completed=False` and `interrupted=True`.
**Return value:** Ignored.
**Return value:** Ignored. Despite its name this hook fires after **every turn**, so it can't show the user anything. To show something when a session ends, return a message from [`on_session_finalize`](#on_session_finalize).
**Use cases:** Flushing buffers, closing connections, persisting session state, logging session duration, cleanup of resources initialized in `on_session_start`.
@@ -1028,7 +1028,28 @@ def my_callback(session_id: str | None, platform: str, **kwargs):
**Fires:** In CLI/TUI teardown (including the end of a `hermes -z` one-shot run, success or failure) and in gateway reset or shutdown paths. Gateway shutdown can finalize without a matching `on_session_reset`.
**Return value:** Ignored.
**Return value — show the user a message:** return a non-empty `str`, or a dict with a `"message"` string, and Hermes shows it to the user as a notice. It never becomes a model turn and never edits the assistant's reply. Return `None` to show nothing. Where it appears:
| Surface | Shown as |
|---------|---------|
| CLI `/new` | Printed in the terminal before the new session starts. |
| CLI quit | Printed above the exit summary (`hermes --resume …`). |
| `hermes chat -q` / `-z` one-shot | Printed to **stderr**, so stdout still holds only the answer. |
| TUI `/new` | A system line in the fresh session's transcript. |
| Messaging gateway `/new` or `/reset` | Sent to the chat that owned the session, as its own message. |
| Gateway shutdown / restart | Sent to each active chat before the adapters disconnect. |
It is not delivered when nobody is there to see it: idle-expiry and cache eviction in the gateway, quitting the TUI, TUI/Desktop sessions closed by the reaper or a disconnect, and Desktop new-chat or chat delete. It is also not delivered from cron jobs, which don't finalize sessions. In those cases the message is dropped and the hook still runs.
```python
def digest(session_id, **kwargs):
count = _edits.pop(session_id, 0)
if count:
return {"message": f"📝 This session edited {count} file(s)."}
def register(ctx):
ctx.register_hook("on_session_finalize", digest)
```
**Use cases:** Persist final session metrics before the session ID is discarded, close per-session resources, emit a final telemetry event, drain queued writes.