"""Session lifecycle for the interactive CLI: new/resume/save, undo/retry rewinds, yolo persistence, manual compression, and exit summary. Mixin bound onto ``HermesCLI`` via the MRO. cli.py-internal symbols are imported LAZILY inside each method (``from cli import ...``) — never at module load time (import cycle). """ from __future__ import annotations import contextlib import os import shutil import sys from agent.i18n import t from hermes_constants import get_hermes_home from hermes_state_ids import new_session_id from pathlib import Path from rich.console import Console from rich.markup import escape as _escape from typing import Any, Dict, List, Optional def _user_turn_indices(history: list) -> list[int]: """Indices of *real* user turns: excludes ephemeral scaffolding, display_kind timeline rows and compaction handoffs — the same predicate as resume turn counting.""" from agent.context_compressor import user_originated_turn_view from agent.session_persistence import _is_ephemeral_scaffolding return [ i for i, m in enumerate(history) if not _is_ephemeral_scaffolding(m) and user_originated_turn_view(m) is not None] def _timestamp_or(value, default): """``datetime.fromtimestamp(value)`` or *default* when the value is missing/unparseable.""" from cli import datetime if not value: return default try: return datetime.fromtimestamp(float(value)) except Exception: return default def _squash(text: str, limit: int = 120) -> str: """Collapse whitespace and cap to *limit* cells with an ellipsis.""" text = " ".join(text.split()) return text[: limit - 1] + "…" if len(text) > limit else text def _dim_notice(cli, msg: str, quiet: bool) -> None: """Print a dim notice — raw to stderr on quiet (pre-TUI) paths. Module-level so tests can call the resume helpers unbound against a minimal stand-in.""" if quiet: print(msg, file=sys.stderr) else: cli._console_print(f"[dim]{_escape(msg)}[/dim]") def _reset_model_to_config_default(cli, silent: bool) -> None: """/new is a full boundary: re-derive model/provider from config.yaml so a session-only ``/model --session`` switch never leaks into the next session. Best-effort — an unreachable default must never block /new. Module-level helper (like ``_apply_new_session_title``): tests drive ``new_session`` unbound on a SimpleNamespace.""" from cli import CLI_CONFIG, _cprint, _split_model_config_default, logger _model_config = CLI_CONFIG.get("model", {}) if isinstance(_model_config, dict): _raw_default = _model_config.get("default") or _model_config.get("model") or "" _config_provider = _model_config.get("provider", "") else: _raw_default, _config_provider = (_model_config or ""), "" _config_model, _ = _split_model_config_default(_raw_default) if not _config_model or _config_model == getattr(cli, "model", None): return try: from hermes_cli.model_switch import switch_model as _switch_model r = _switch_model( raw_input=_config_model, current_provider=cli.provider or "", current_model=cli.model or "", current_base_url=cli.base_url or "", current_api_key=cli.api_key or "", is_global=False, explicit_provider=_config_provider or "") if not r.success: return if cli.agent: cli.agent.switch_model( new_model=r.new_model, new_provider=r.target_provider, api_key=r.api_key, base_url=r.base_url, api_mode=r.api_mode, capabilities=getattr(r, "runtime_capabilities", None)) cli.model = r.new_model cli.provider = r.target_provider cli.requested_provider = r.target_provider cli._explicit_api_key = r.api_key cli._explicit_base_url = r.base_url if r.api_key: cli.api_key = r.api_key if r.base_url: cli.base_url = r.base_url if r.api_mode: cli.api_mode = r.api_mode if not silent: _cprint(f" {t('cli.session.model_reset_default', model=r.new_model)}") except Exception: logger.debug("/new model reset to config default failed", exc_info=True) def _apply_new_session_title(cli, title: str) -> Optional[str]: """Sanitize + persist a /new title; returns the stored title or None (untitled).""" from cli import _cprint from hermes_state import SessionDB try: sanitized = SessionDB.sanitize_title(title) except ValueError as e: _cprint(f" {t('cli.session.title_rejected', error=e)}") return None if not sanitized: _cprint(f" {t('cli.session.title_empty_untitled')}") return None try: cli._session_db.set_session_title(cli.session_id, sanitized) except ValueError as e: _cprint(f" {t('cli.session.title_error_untitled', error=e)}") return None except Exception: return None cli._pending_title = None cli._status_bar_title_checked_at = 0.0 return sanitized class CLISessionMixin: """Session lifecycle for the interactive CLI: new/resume/save, undo/retry rewinds, yolo persistence, manual compression, and exit summary.""" def _restore_session_cwd(self, session_meta: dict, *, quiet: bool = False) -> None: """Relaunch a resumed session in the directory it was started from. Idempotent; called from every resume path. Both ``os.chdir()`` and ``TERMINAL_CWD`` are retargeted so the process and the terminal/code-exec tools agree (the local terminal backend snapshots cwd on first use, after this). No-op when no cwd was recorded, the directory is gone (dim warning, never a crash), or we're already there. """ recorded = (session_meta or {}).get("cwd") if not recorded: return recorded = os.path.expanduser(str(recorded)) try: current = os.getcwd() except OSError: current = None if current and os.path.realpath(recorded) == os.path.realpath(current): return if not os.path.isdir(recorded): msg = t("cli.session.cwd_gone", recorded=recorded, current=current or ".") else: try: os.chdir(recorded) os.environ["TERMINAL_CWD"] = recorded msg = t("cli.session.cwd_restored", path=recorded) except OSError as e: msg = t("cli.session.cwd_enter_failed", path=recorded, error=e) _dim_notice(self, msg, quiet) def _restore_session_yolo(self, session_meta: dict, *, quiet: bool = False) -> None: """Re-enable YOLO bypass on resume when the session row's ``model_config.yolo_mode`` says so — the in-memory ``tools.approval._session_yolo`` set starts empty in a fresh process. No-op when already active or when the process was launched with ``--yolo``.""" try: from hermes_state import SessionDB from tools.approval import ( _YOLO_MODE_FROZEN, enable_session_yolo, is_session_yolo_enabled) except Exception: return if _YOLO_MODE_FROZEN or not SessionDB.session_yolo_enabled(session_meta): return session_key = self.session_id or "default" if is_session_yolo_enabled(session_key): return enable_session_yolo(session_key) _dim_notice(self, t("cli.session.yolo_restored"), quiet) def _render_resume_history_panel_lines(self, panel) -> list[str]: """Render the resume panel at the current terminal width for resize replay.""" from cli import _suspend_output_history from io import StringIO buf = StringIO() console = Console( file=buf, force_terminal=True, color_system="truecolor", highlight=False, width=shutil.get_terminal_size((80, 24)).columns) with _suspend_output_history(): console.print(panel) return buf.getvalue().rstrip("\n").splitlines() def _resolve_checkpoint_ref(self, ref: str, checkpoints: list) -> str | None: """Resolve a 1-indexed checkpoint number (or pass through a git hash).""" try: idx = int(ref) - 1 except ValueError: return ref if 0 <= idx < len(checkpoints): return checkpoints[idx]["hash"] print(f" {t('cli.session.invalid_checkpoint', count=len(checkpoints))}") return None def _show_status(self): """Show compact startup status line.""" from cli import get_tool_definitions # Avoid pulling the full tool registry into the bare Termux prompt path. if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1": tool_status = t("cli.session.status_tools_deferred") else: tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets, quiet_mode=True) tool_status = t("cli.session.status_tools_count", count=len(tools) if tools else 0) model_short = self.model.split("/")[-1] if "/" in self.model else self.model if len(model_short) > 30: model_short = model_short[:27] + "..." api_indicator = "[green bold]●[/]" if self.api_key else "[red bold]●[/]" try: from hermes_cli.skin_engine import get_active_skin skin = get_active_skin() separator_color = skin.get_color("banner_dim", "#B8860B") accent_color = skin.get_color("ui_accent", "#FFBF00") label_color = skin.get_color("ui_label", "#DAA520") except Exception: separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan" sep = f" [dim {separator_color}]·[/] " toolsets_info = "" if self.enabled_toolsets and "all" not in self.enabled_toolsets: toolsets_info = f"{sep}[{label_color}]{_escape(t('cli.session.status_toolsets', toolsets=', '.join(self.enabled_toolsets)))}[/]" provider_info = f"{sep}[dim]{_escape(t('cli.session.status_provider', provider=self.provider))}[/]" if self._provider_source: provider_info += f"{sep}[dim]{_escape(t('cli.session.status_auth', source=self._provider_source))}[/]" self._console_print( f" {api_indicator} [{accent_color}]{model_short}[/]{sep}" f"[bold {label_color}]{tool_status}[/]{toolsets_info}{provider_info}") def _show_session_status(self): """Show gateway-style status for the current CLI session.""" from hermes_cli.status_report import build_status_fields, status_lines session_meta = {} if self._session_db: with contextlib.suppress(Exception): session_meta = self._session_db.get_session(self.session_id) or {} agent = getattr(self, "agent", None) fields = build_status_fields( self.session_id, agent, session_meta, model=getattr(self, "model", None), provider=getattr(self, "provider", None), created_fallback=self.session_start, agent_running=bool(getattr(self, "_agent_running", False)), ) reasoning_label = None rc = getattr(agent, "reasoning_config", None) or getattr(self, "reasoning_config", None) if isinstance(rc, dict): if rc.get("enabled") is False: reasoning_label = t("cli.shared.label_off") elif rc.get("effort"): reasoning_label = str(rc.get("effort")) show_r = getattr(self, "show_reasoning", None) if reasoning_label and show_r is not None: state = t("cli.shared.label_on") if show_r else t("cli.shared.label_off") reasoning_label += t("cli.session.reasoning_display_suffix", state=state) approval_label = None try: from tools.approval import is_approval_bypass_active_for_session from tools.approval_context import _get_approval_mode approval_label = _get_approval_mode() if is_approval_bypass_active_for_session(getattr(self, "session_key", "") or ""): approval_label += t("cli.session.yolo_bypass_suffix") except Exception: pass # Context window usage: reuse the status-bar snapshot (tokens / max / percent). ctx_label = None try: snap = self._get_status_bar_snapshot() ctx_max = snap.get("context_length") ctx_pct = snap.get("context_percent") if ctx_max: left = "" if isinstance(ctx_pct, (int, float)): left = t("cli.session.ctx_left_prefix", percent=max(0, 100 - int(ctx_pct))) ctx_label = t("cli.session.ctx_tokens_used", left=left, used=f"{snap.get('context_tokens') or 0:,}", max=f"{ctx_max:,}") except Exception: ctx_label = None lines = [t("cli.session.status_title"), "", *status_lines(fields, "session_id", "path", "title", "model")] try: from hermes_cli.anon_auth import free_tier_route if free_tier_route(): lines.append(t("gateway.status.free_tier")) except Exception: pass optional = ((t("cli.session.label_reasoning"), reasoning_label), (t("cli.session.label_approvals"), approval_label), (t("cli.session.label_context"), ctx_label)) for label, value in optional: if value: lines.append(t("cli.shared.label_value", label=label, value=value)) lines.extend(status_lines(fields, "created", "last_activity", "tokens", "agent_running")) self._console_print("\n".join(lines), highlight=False, markup=False) def _list_recent_sessions(self, limit: int = 10) -> list[dict[str, Any]]: """Return recent CLI sessions for in-chat browsing/resume affordances.""" if not self._session_db: return [] try: from hermes_cli.session_listing import query_session_listing from hermes_state_sessions import INTERNAL_LISTING_SOURCES return query_session_listing( self._session_db, source="cli", current_session_id=self.session_id, include_all_sources=False, include_unnamed=True, limit=limit, exclude_sources=list(INTERNAL_LISTING_SOURCES)) except Exception: return [] def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool: """Render recent sessions inline from the active chat TUI. Returns True when something was shown, False if no session list was available. """ from cli import _cli_visible_print sessions = self._list_recent_sessions(limit=limit) if not sessions: return False from hermes_cli.timefmt import relative_time as _relative_time _cli_visible_print() if reason == "history": _cli_visible_print(t("cli.session.recent_intro")) else: _cli_visible_print(f" {t('cli.session.recent_header')}") _cli_visible_print() _cli_visible_print( f" {'#':<3} {t('cli.session.column_title'):<32} {t('cli.session.column_preview'):<40} " f"{t('cli.session.column_last_active'):<13} {t('cli.session.column_id')}") _cli_visible_print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") for idx, session in enumerate(sessions, start=1): title = session.get("title") or "—" preview = (session.get("preview") or "")[:38] last_active = _relative_time(session.get("last_active"), session_id=session.get("id")) _cli_visible_print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}") _cli_visible_print() _cli_visible_print(f" {t('cli.session.resume_usage')}") _cli_visible_print(f" {t('cli.session.resume_example')}") _cli_visible_print() return True def show_history(self): """Display conversation history.""" from cli import _cli_visible_print if not self.conversation_history: if not self._show_recent_sessions(reason="history"): _cli_visible_print(t("cli.session.history_empty")) return preview_limit = 400 visible_index = 0 hidden_tool_messages = 0 show_ts = bool(getattr(self, "show_timestamps", False)) def _ts_suffix(message: dict) -> str: # Only annotate when the toggle is on AND the turn has a stored unix # `timestamp` (SessionDB-restored rows do; live turns may not) — never fabricate. ts = message.get("timestamp") if show_ts else None if not ts: return "" try: stamp = _timestamp_or(ts, None) return f" [{stamp.strftime(getattr(self, 'timestamp_format', '%H:%M'))}]" if stamp else "" except (ValueError, OSError, TypeError): return "" def flush_tool_summary(): nonlocal hidden_tool_messages if not hidden_tool_messages: return key = "cli.session.history_tools_hidden_one" if hidden_tool_messages == 1 else "cli.session.history_tools_hidden_other" _cli_visible_print(f"\n {t('cli.session.history_tools_header')}") _cli_visible_print(f" {t(key, count=hidden_tool_messages)}") hidden_tool_messages = 0 rule = "+" + "-" * 50 + "+" for line in ("", rule, "|" + t("cli.session.history_title").center(50) + "|", rule): _cli_visible_print(line) for msg in self.conversation_history: role = msg.get("role", "unknown") if role == "tool": hidden_tool_messages += 1 continue if role not in {"user", "assistant"}: continue flush_tool_summary() visible_index += 1 content = msg.get("content") content_text = "" if content is None else str(content) preview = content_text[:preview_limit] suffix = "..." if len(content_text) > preview_limit else "" if role == "user": _cli_visible_print(f"\n {t('cli.session.history_you_header', index=visible_index)}{_ts_suffix(msg)}") _cli_visible_print(f" {preview}{suffix}") continue _cli_visible_print( f"\n {t('cli.session.history_assistant_header', agent_name='Hermes', index=visible_index)}{_ts_suffix(msg)}") n_calls = len(msg.get("tool_calls") or []) if not content_text: suffix = "" preview = t("cli.session.history_no_text") if n_calls: key = "cli.session.history_tool_calls_one" if n_calls == 1 else "cli.session.history_tool_calls_other" preview = t(key, count=n_calls) _cli_visible_print(f" {preview}{suffix}") flush_tool_summary() _cli_visible_print() def _notify_session_boundary(self, event_type: str) -> None: """Fire a session-boundary plugin hook (on_session_finalize / on_session_reset). Non-blocking; errors swallowed. Safe from shutdown, /new, /reset.""" with contextlib.suppress(Exception): from hermes_cli.lifecycle import finalize_session, invoke_hook context = { "session_id": self.agent.session_id if self.agent else None, "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) else: invoke_hook(event_type, **context) def _discard_session_if_empty(self, session_id: Optional[str]) -> bool: """Drop a just-ended session row that never gained content (quit-immediately, /new, /clear) so it doesn't clutter ``/resume``. ``SessionDB.delete_session_if_empty`` only removes rows with no messages, no title and no children (gemini-cli#27770 port).""" from cli import logger if not self._session_db or not session_id: return False # In-memory transcript is authoritative: a real conversation whose DB flush failed # or hasn't happened yet must never be pruned. if getattr(self, "conversation_history", None): return False try: from hermes_constants import get_hermes_home as _ghh return self._session_db.delete_session_if_empty( session_id, sessions_dir=_ghh() / "sessions") except Exception: logger.debug("Could not prune empty session %s", session_id, exc_info=True) return False def _launch_session_boundary_memory_flush( self, history_snapshot: list, *, session_id: Optional[str] = None) -> Optional[list]: """Stage old-session memory extraction so /new stays responsive. The context-engine ``on_session_end`` is delivered synchronously here: cheap (no LLM) and ordering-sensitive — it must land before ``reset_session_state()`` rebinds the engine. The memory-provider half (LLM-bound, seconds) is NOT run here: the returned snapshot goes to ``MemoryManager.commit_session_boundary_async`` as one end→switch task on the serialized worker, so a late ``on_session_end`` can never run after ``on_session_switch`` and misattribute the old transcript to the new session. Returns the snapshot to queue, or ``None`` when there is nothing to extract. """ from cli import logger agent = getattr(self, "agent", None) if not agent or not history_snapshot: return None engine = getattr(agent, "context_compressor", None) if engine is not None and hasattr(engine, "on_session_end"): try: engine.on_session_end(session_id or "", history_snapshot) except Exception: logger.debug("Context engine on_session_end failed at /new boundary", exc_info=True) # No memory manager → new_session() falls back to the inline switch path. if getattr(agent, "_memory_manager", None) is None: return None return history_snapshot def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" from cli import ( CLI_CONFIG, _parse_service_tier_config, _sync_process_session_id, datetime) from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning old_session_id = self.session_id _boundary_snapshot = None if self.agent: if self.conversation_history: # Context-engine boundary now; provider extraction is queued below (after # rotation) so /new never blocks on the LLM-bound call. _boundary_snapshot = self._launch_session_boundary_memory_flush( list(self.conversation_history), session_id=old_session_id) self._notify_session_boundary("on_session_finalize") if self._session_db and old_session_id: # /new can arrive mid-turn before _flush_messages_to_session_db() ran — flush # the current turn to the OLD session before rotating or it is silently lost. if self.agent: with contextlib.suppress(Exception): # Flush any un-persisted messages from the current turn to the old session *before* # rotating. /new can be called mid-turn when _flush_messages_to_session_db() has not # yet run — without this, messages generated during the current turn are silently lost # on session rotation (#47202). # See #47202. # See #47202. self.agent._flush_messages_to_session_db( self.conversation_history, conversation_history=self.conversation_history) with contextlib.suppress(Exception): self._session_db.end_session(old_session_id, "new_session") self._discard_session_if_empty(old_session_id) self.session_start = datetime.now() self.session_id = new_session_id(self.session_start) # getattr: tests drive new_session unbound against a SimpleNamespace stand-in. getattr(self, "_write_terminal_breadcrumb", lambda: None)() self.conversation_history = [] self._pending_title = None self._resumed = False # An explicit -m/--model was for the previous session only. self._explicit_model_override = False # Session-scoped overrides (/model --session, /fast, one-turn restores) don't carry over. # Re-derive model/provider and service tier from config.yaml so a session-only switch never leaks # into the next session (#48055, #23131). self._pending_one_turn_model_restore = None self.service_tier = _parse_service_tier_config(CLI_CONFIG["agent"].get("service_tier", "")) _reset_model_to_config_default(self, silent) # After the model reset: the effort belongs to the model the fresh session lands on (a /reasoning # session override is dropped, the default model's per-model override is kept). _resolve_cli_reasoning(self) _sync_process_session_id(self.session_id) if self.agent: self.agent.session_id = self.session_id self.agent.session_start = self.session_start self.agent.reasoning_config = self.reasoning_config self.agent.reset_session_state() if hasattr(self.agent, "_last_flushed_db_idx"): self.agent._last_flushed_db_idx = 0 if hasattr(self.agent, "_todo_store"): with contextlib.suppress(Exception): from tools.todo_tool import TodoStore self.agent._todo_store = TodoStore() if hasattr(self.agent, "_invalidate_system_prompt"): self.agent._invalidate_system_prompt() if self._session_db: with contextlib.suppress(Exception): self.agent._session_db_created = False self._session_db.create_session( session_id=self.session_id, source=os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=self.model, model_config={ "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, }) self.agent._session_db_created = True if title: title = _apply_new_session_title(self, title) # Tell memory providers the session_id rotated (reset=True flushes per-session # state) BEFORE the plugin on_session_reset hook. With old history, end-of-session # extraction and this switch are queued as ONE task on the serialized worker — # end strictly before switch, without blocking /new. No history → switch inline. _mm = getattr(self.agent, "_memory_manager", None) with contextlib.suppress(Exception): if _mm is not None and _boundary_snapshot: _mm.commit_session_boundary_async( _boundary_snapshot, new_session_id=self.session_id, parent_session_id=old_session_id or "", reason="new_session") elif _mm is not None: _mm.on_session_switch( self.session_id, parent_session_id=old_session_id or "", reset=True, reason="new_session") self._notify_session_boundary("on_session_reset") if not silent: if title: print(t("cli.session.new_session_titled", title=title)) else: print(t("cli.session.new_session")) def _consume_pending_resume_selection(self, text: str) -> bool: """Resolve a bare numeric reply following a bare ``/resume`` prompt. ``/resume`` (no args) arms ``self._pending_resume_sessions``; the next input gets one chance to be a bare session number. The pending state is one-shot — cleared on the first input regardless of outcome, so a stray later number is never hijacked. Returns True if the input was consumed (caller must not treat it as chat). See #34584. """ from cli import _cprint pending = self._pending_resume_sessions if not pending: return False self._pending_resume_sessions = None if not isinstance(text, str): return False # Only a pure number selects; "/resume 3", titles etc. fall through. if not text.strip().isdigit(): return False index = int(text.strip()) if not 1 <= index <= len(pending): _cprint(f" {t('cli.session.resume_index_out_of_range', index=index)}") _cprint(f" {t('cli.session.resume_no_args_hint')}") return True self._handle_resume_command(f"/resume {index}") return True def save_conversation(self, cmd: str = "/save"): """Handle ``/save [json|md|html] [filename] [redact]``. A convenience export only — every message is already persisted to the session DB, so the live session stays resumable regardless. ``redact`` runs the force-mode secret redaction pass before writing. """ from cli import datetime from hermes_cli.session_export import ( SAVE_USAGE, load_save_snapshot, normalize_save_format, render_session_for_save) parts = cmd.split()[1:] redact = bool(parts) and parts[-1].lower() in ("redact", "--redact") if redact: parts = parts[:-1] if not parts: print(SAVE_USAGE) return try: fmt = normalize_save_format(parts[0]) except ValueError as e: print(f"(._.) {e}") print(SAVE_USAGE) return filename = parts[1] if len(parts) > 1 else None # Prefer the durable DB row (metadata + tool calls); fall back to in-memory history. # getattr: test doubles may not carry _session_db / session_id. session_data = None _db = getattr(self, "_session_db", None) _sid = getattr(self, "session_id", None) if _db and _sid: from hermes_state import SessionExportTooLargeError try: session_data = load_save_snapshot(_db, _sid, fmt) except SessionExportTooLargeError as e: print(f"(._.) {e}") return except Exception: session_data = None if not session_data: if not self.conversation_history: print(t("cli.session.save_nothing")) return session_data = { "id": self.session_id, "model": self.model, "started_at": self.session_start.timestamp(), "messages": self.conversation_history} if redact: from hermes_cli.session_export_md import redact_session_data session_data = redact_session_data(session_data) saved_dir = get_hermes_home() / "sessions" / "saved" try: saved_dir.mkdir(parents=True, exist_ok=True) except Exception as e: print(t("cli.session.save_dir_failed", path=saved_dir, error=e)) return if filename: path = Path(filename).expanduser() if not path.is_absolute(): path = Path.cwd() / path else: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") path = saved_dir / f"hermes_conversation_{timestamp}.{fmt}" try: content = render_session_for_save(session_data, fmt) with open(path, "w", encoding="utf-8") as f: f.write(content) label = {"json": "JSON", "md": t("cli.session.format_markdown"), "html": "HTML"}[fmt] print(t("cli.session.save_ok", path=path, label=label)) # #76354 review F5: the worker thread also rebound the session ContextVar inside its own # (copied) context, which the caller never sees — and get_session_env() prefers an already-bound # ContextVar over os.environ. Rebind in the CALLER's context so post-compression # tools/subprocesses on this thread resolve HERMES_SESSION_ID to the child id after an # out-of-place rotation (idempotent when no rotation happened). if self.session_id: print(f" {t('cli.session.save_resume_hint', session_id=self.session_id)}") except Exception as e: print(t("cli.session.save_failed", error=e)) def _publish_truncated_history(self, truncated: list, *, invalidate_prompt: bool) -> None: """Install a rewound history and mirror it onto the agent (flush index reset so the next turn re-flushes from the truncated head).""" self.conversation_history = truncated agent = self.agent if agent is None: return if invalidate_prompt and hasattr(agent, "_invalidate_system_prompt"): with contextlib.suppress(Exception): agent._invalidate_system_prompt() if hasattr(agent, "_last_flushed_db_idx"): with contextlib.suppress(Exception): agent._last_flushed_db_idx = len(self.conversation_history) if hasattr(agent, "_session_messages"): agent._session_messages = self.conversation_history if hasattr(agent, "_db_flush_scan_prefix"): agent._db_flush_scan_prefix = self.conversation_history[:] def retry_last(self): """Retry the last user message: drop the last exchange and return the text to re-send (None when there is nothing to retry).""" if not self.conversation_history: print(t("cli.session.retry_no_messages")) return None from agent.context_compressor import ( history_before_user_originated_turn, retryable_user_text) from agent.memory_manager import sanitize_context warm_history = list(self.conversation_history) user_indices = _user_turn_indices(warm_history) if not user_indices: print(t("cli.session.retry_no_user_message")) return None # Resolve a lossless live payload before touching persistence or memory. A # force-user-leading compaction row is one physical carrier: its handoff stays in # the prefix while only the embedded human ask is retried. Media cannot be replayed # by /retry, so fail closed before archiving anything. try: truncated, live_view = history_before_user_originated_turn( warm_history, user_indices[-1]) live_content = live_view.get("content") if isinstance(live_content, str): live_content = sanitize_context(live_content).strip() last_message = retryable_user_text(live_content) except ValueError as exc: print(t("cli.session.retry_unsafe", error=exc)) return None # Persist the rewind before publishing the shorter in-memory view: the DB owns the # physical carrier split so archived original + retained scaffold commit atomically. if self._session_db is not None and self.session_id: try: truncated = self._session_db.rewind_user_turn( self.session_id, -1, warm_history=warm_history, require_retryable=True).prefix except Exception as exc: print(t("cli.session.retry_rewind_failed", error=exc)) return None self._publish_truncated_history(truncated, invalidate_prompt=False) print(t("cli.session.retrying", preview=f"{last_message[:60]}{'...' if len(last_message) > 60 else ''}")) return last_message def undo_last(self, n: int = 1, prefill: bool = True): """Back up N user turns: truncate history, soft-delete on disk, prefill the composer. Discards everything from the Nth-from-last user message onward (clamped to the oldest turn). Rows are soft-deleted in SessionDB (``active=0``, kept for audit), memory providers get ``on_session_switch(rewound=True)``, and the agent is patched like /branch does. ``prefill=False`` is for programmatic callers (checkpoint rollback) that must not touch the input buffer. Returns the number of user turns undone (None when nothing changed). """ from cli import logger if not self.conversation_history: print(t("cli.session.undo_no_messages")) return n = max(n, 1) from agent.context_compressor import history_before_user_originated_turn warm_history = list(self.conversation_history) user_indices = _user_turn_indices(warm_history) if not user_indices: print(t("cli.session.undo_no_user_message")) return turns_undone = min(n, len(user_indices)) target_ordinal = len(user_indices) - turns_undone cut_idx = user_indices[target_ordinal] removed_count = len(warm_history) - cut_idx truncated, live_view = history_before_user_originated_turn(warm_history, cut_idx) removed_text = self._undo_content_to_text(live_view.get("content")) rewound_rows = 0 if self._session_db is not None and self.session_id: try: outcome = self._session_db.rewind_user_turn( self.session_id, target_ordinal, warm_history=warm_history) truncated = outcome.prefix # Canonical editable prefill: the raw carrier holds the reference-summary wrapper. removed_text = outcome.live_text or removed_text rewound_rows = outcome.rewound_count except Exception as e: logger.debug("undo: durable rewind failed: %s", e) print(t("cli.session.undo_failed", error=e)) return # Publish only after the durable rewind succeeds (or no store exists). self._publish_truncated_history(truncated, invalidate_prompt=True) # Same hook /branch fires; rewound=True invalidates per-turn document caches. _mm = getattr(self.agent, "_memory_manager", None) # See #21910, #6672. if _mm is not None and self.session_id: with contextlib.suppress(Exception): _mm.on_session_switch(self.session_id, parent_session_id="", reset=False, rewound=True) key = "cli.session.undo_ok_one" if turns_undone == 1 else "cli.session.undo_ok_other" print(t(key, count=turns_undone, messages=rewound_rows or removed_count, backup=f"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}")) print(f" {t('cli.session.undo_remaining', count=len(self.conversation_history))}") # Editable, not auto-sent (Claude-Code-style). if prefill and removed_text: self._prefill_input_buffer(removed_text) return turns_undone @staticmethod def _undo_content_to_text(content) -> str: """Flatten message content (str or content-part list) to plain text.""" if isinstance(content, str): return content if isinstance(content, list): parts = [ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" ] return "\n".join(t for t in parts if t) return "" def _write_terminal_breadcrumb(self) -> None: """Record this terminal's live session for bare ``hermes -c``. Called whenever ``self.session_id`` is (re)assigned so a later bare ``-c`` in THIS terminal resumes this conversation's live tip. Best-effort; no-op without a terminal identity.""" with contextlib.suppress(Exception): from hermes_cli.terminal_breadcrumbs import write_breadcrumb write_breadcrumb(self.session_id) def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None: """Move YOLO bypass state to a new session key when ``self.session_id`` is reassigned mid-run (/branch, auto-compression rotation) — ``_session_yolo`` is keyed by id, so without this the toggle silently reverts. Mirrors tui_gateway's rename path.""" if not old_session_id or not new_session_id or old_session_id == new_session_id: return try: from tools.approval import ( disable_session_yolo, enable_session_yolo, is_session_yolo_enabled) except Exception: return if is_session_yolo_enabled(old_session_id): enable_session_yolo(new_session_id) disable_session_yolo(old_session_id) # Carry the persisted flag onto the continuation row so a later --resume restores # it too. getattr: tests call this unbound against a minimal stand-in. _persist = getattr(self, "_persist_session_yolo", None) if _persist: _persist(new_session_id, True) def _is_session_yolo_active(self) -> bool: """Whether YOLO bypass is on for this session: reads ``tools.approval._session_yolo`` (not a stale env var) and honors the frozen process-start ``--yolo`` flag.""" try: from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled except Exception: return False if _YOLO_MODE_FROZEN: return True # getattr: __new__-built test fixtures skip __init__; the status-bar builders # swallow exceptions but would lose every field after the failure. return is_session_yolo_enabled(getattr(self, "session_id", None) or "default") def _toggle_yolo(self): """Toggle per-session YOLO mode (skip dangerous-command approvals). Mirrors the gateway/TUI ``/yolo`` handlers. Deliberately does NOT touch ``HERMES_YOLO_MODE``: that env var is frozen into ``tools.approval._YOLO_MODE_FROZEN`` at import (so prompt-injected skills can't flip the bypass), making a later set a silent no-op. ``run_conversation`` binds ``self.session_id`` as the active approval key, so the bypass applies to the very next dangerous command. """ from cli import _cprint from hermes_cli.colors import Colors as _Colors from tools.approval import ( _YOLO_MODE_FROZEN, disable_session_yolo, enable_session_yolo, is_session_yolo_enabled) # A frozen process-level bypass short-circuits the approval gate ahead of the session # check — toggling "OFF" would be a false safety claim. Say so instead. if _YOLO_MODE_FROZEN: state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.session.yolo_state_locked_on')}{_Colors.RESET}" _cprint(f" {t('cli.session.yolo_locked', state=state)}") return session_key = self.session_id or "default" # getattr: tests call this unbound against a minimal stand-in; persistence is best-effort. _persist = getattr(self, "_persist_session_yolo", None) if is_session_yolo_enabled(session_key): disable_session_yolo(session_key) if _persist: _persist(session_key, False) state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.shared.label_off_upper')}{_Colors.RESET}" _cprint(f" {t('cli.session.yolo_off', state=state)}") else: enable_session_yolo(session_key) if _persist: _persist(session_key, True) state = f"{_Colors.BOLD}{_Colors.GREEN}{t('cli.shared.label_on_upper')}{_Colors.RESET}" _cprint(f" {t('cli.session.yolo_on', state=state)}") def _persist_session_yolo(self, session_key: str, enabled: bool) -> None: """Persist the YOLO flag to the session row so --resume restores it. Best-effort; the in-memory toggle is authoritative. Skipped without a store or before the row exists (rows are created lazily on the first turn).""" db = getattr(self, "_session_db", None) if db is None or not session_key or session_key == "default": return with contextlib.suppress(Exception): db.set_session_yolo(session_key, enabled) def _manual_compress(self, cmd_original: str = ""): """Manually trigger context compression. * ``/compress []`` — compress the whole history; an optional focus topic tells the summariser what to preserve while discarding the rest more aggressively. * ``/compress here [N]`` — boundary-aware: summarize everything except the most recent ``N`` exchanges (default 2), kept verbatim. * ``--preview`` reports what would happen and changes nothing. No ``compression_enabled`` gate: that flag disables *automatic* compaction only, and the context-overflow error path directs users here when it is off. """ from agent.conversation_compression import finalize_context_engine_compression_notification from agent.conversation_compression_manual import ( AGGRESSIVE_UNSUPPORTED, MIN_MESSAGES, compress_now, parse_compress_args, render_compress_result) if len(self.conversation_history or ()) < MIN_MESSAGES: print(t("cli.session.compress_too_short", min=MIN_MESSAGES)) return if not self.agent: print(t("cli.session.compress_no_agent")) return _parts = (cmd_original or "").strip().split(None, 1) request = parse_compress_args(_parts[1] if len(_parts) > 1 else "") if request.aggressive: print(f"(._.) {AGGRESSIVE_UNSUPPORTED}") if not request.preview: return if request.preview: for line in render_compress_result(compress_now(self.agent, self.conversation_history, request)): print(f"🗜️ {line}") return original_count = len(self.conversation_history) with self._busy_command(t("cli.session.compress_busy"), blocks_input=False): try: if request.partial: print(t("cli.session.compress_summarizing", count=original_count, keep_last=request.keep_last)) elif request.focus_topic: print(t("cli.session.compress_focus", count=original_count, focus=request.focus_topic)) else: print(t("cli.session.compress_plain", count=original_count)) result = compress_now(self.agent, self.conversation_history, request, task_id=self.session_id or "default") if result.status != "compressed": for line in render_compress_result(result): print(f" {line}") return self.conversation_history = result.after_messages # _compress_context ends the old session and creates a child session on the # agent. Sync the CLI's session_id so /status, /resume, exit summary and title # generation point at the live continuation, not the ended parent. agent_sid = getattr(self.agent, "session_id", None) if agent_sid and agent_sid != self.session_id: self.session_id = self.agent.session_id self._write_terminal_breadcrumb() self._pending_title = None # Persist the new handoff from offset 0 so resume can recover it after exit. self.agent._flush_messages_to_session_db(self.conversation_history, None) finalize_context_engine_compression_notification(self.agent, committed=True) summary = result.summary if summary.get("aborted") or summary.get("fallback_used") or summary.get("refused_would_grow"): icon = "⚠️" else: icon = "🗜️" if summary["noop"] else "✅" print(f" {icon} {summary['headline']}") print(f" {summary['token_line']}") if summary["note"]: print(f" {summary['note']}") except Exception as e: finalize_context_engine_compression_notification(self.agent, committed=False) print(f" {t('cli.session.compress_failed', error=e)}") def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None: """Print a one-line scrollback summary of a resolved modal prompt (approval/clarify panels vanish on repaint); gated by ``display.persist_prompts``.""" from cli import CLI_CONFIG, _DIM, _RST, _cprint if not CLI_CONFIG.get("display", {}).get("persist_prompts", True): return detail, outcome = (_squash(s) for s in (detail, outcome)) _cprint(f"\n{_DIM}{t('cli.session.persist_prompt_summary', icon=icon, label=label, detail=detail, outcome=outcome)}{_RST}") def _clear_terminal_on_exit(self): """Clear screen + scrollback (``ESC[3J ESC[2J ESC[H``) so nothing is stranded above the exit summary. Only safe after ``app.run()`` returned and prompt_toolkit restored terminal modes. Skips when stdout isn't a console; falls back to ``clear``/``cls``.""" try: stream = sys.stdout if stream is None or not stream.isatty(): return except Exception: return try: stream.write("\033[3J\033[2J\033[H") stream.flush() except Exception: # Fallback for terminals that reject the escape sequence. Never os.system(): # it spawns a cmd.exe/shell window that flashes on Windows (#116904) and a # minimal container without `clear` on PATH just no-ops through the shell. try: import subprocess from hermes_cli._subprocess_compat import windows_hide_flags if os.name == "nt": argv = ["cmd", "/c", "cls"] # `cls` is a cmd builtin, not an exe else: clear_bin = shutil.which("clear") argv = [clear_bin] if clear_bin else [] if argv: subprocess.run( argv, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), check=False, ) except Exception: pass def _persist_active_session_before_close(self): """Best-effort flush of the agent's live ``_session_messages`` before ``end_session()`` — a terminal close/SIGHUP can unwind the app while the agent thread still holds the current turn only in memory.""" from cli import logger agent = getattr(self, "agent", None) if not agent or not hasattr(agent, "_persist_session"): return persist_lock = getattr(agent, "_session_persist_lock", None) def _snapshot_and_persist() -> None: # Must share the staging lock with ``chat()``: otherwise close can retain a # history baseline just before chat appends its pending dict, and the later flush # stamps that dict durable without writing a row. messages = getattr(agent, "_session_messages", None) pending_cli_message = getattr(agent, "_pending_cli_user_message", None) if not isinstance(messages, list): messages = getattr(self, "conversation_history", None) if not isinstance(messages, list): return if isinstance(pending_cli_message, dict) and not any( m is pending_cli_message for m in messages): # The UI accepted a new input but the worker still exposes its prior snapshot. messages = [*messages, pending_cli_message] if not messages: return # Baseline: the CLI history a normal turn built its new list from, so a signal # between assigning ``_session_messages`` and the DB flush cannot append the # durable prefix twice. When both names alias the same live list, marker-only # persistence would mark an unflushed tail durable — pass None instead. conversation_history = getattr(self, "conversation_history", None) if ( isinstance(conversation_history, list) and conversation_history and conversation_history[-1] is pending_cli_message): # Accepted but not yet durable: exclude it from the resumed-history baseline. conversation_history = conversation_history[:-1] elif not isinstance(conversation_history, list) or conversation_history is messages: conversation_history = None # A first-turn close can precede the cached prompt; build it so the durable # transcript never gets a NULL system_prompt cache entry. if getattr(agent, "_cached_system_prompt", None) is None: try: from agent.conversation_loop import _restore_or_build_system_prompt _restore_or_build_system_prompt(agent, None, conversation_history) except Exception: logger.debug("Could not build system prompt during CLI close", exc_info=True) return if getattr(agent, "_cached_system_prompt", None) is None: return agent._ensure_db_session() agent._persist_session(messages, conversation_history) if getattr(agent, "session_id", None): self.session_id = agent.session_id self._write_terminal_breadcrumb() try: # Create the DB session row now that _cached_system_prompt is populated, so the persisted # snapshot is written non-NULL on the first turn (Issue #45499). Idempotent: # _ensure_db_session() no-ops once the row exists. Must run BEFORE preflight compression: # in-place compaction inserts message rows referencing this session (archive_and_compact), and # rotation creates a child with parent_session_id pointing at it — with PRAGMA foreign_keys=ON, # a missing parent row fails both INSERTs on a fresh oversized first turn. The user-turn crash # persist itself runs LATER (after memory prefetch / pre_llm_call), so the row is written once # with its final api_content — both steps take the same per-agent persist lock as CLI close # persistence. if persist_lock is None: _snapshot_and_persist() else: with persist_lock: _snapshot_and_persist() except (Exception, KeyboardInterrupt) as e: logger.debug("Could not persist active CLI session before close: %s", e) def _print_exit_summary(self, clear_screen: bool = True): """Print session resume info on exit. ``clear_screen`` (interactive TUI teardown) wipes screen + scrollback first; single-query mode passes False to keep the answer. Args: clear_screen: When True (default), clear the terminal screen and scrollback before printing the summary. See #38252, #53009. """ from cli import datetime if clear_screen: # Clear the screen + scrollback before printing the summary so the live bottom chrome (status # bar, input box, separator rules) and the rest of the session transcript don't get stranded # above the exit summary (#38252). By this point app.run() has returned and prompt_toolkit has # restored terminal modes, so writing raw escapes to stdout is safe. ESC[3J clears scrollback, # ESC[2J clears the visible screen, ESC[H homes the cursor — so the summary prints at a clean # top-left. Falls back to the platform clear command if stdout isn't a TTY-capable stream. # Honors NO_COLOR/dumb terminals by skipping silently when there's no real console. self._clear_terminal_on_exit() print() msg_count = len(self.conversation_history) if not msg_count: try: from hermes_cli.skin_engine import get_active_goodbye goodbye = get_active_goodbye(t("cli.session.goodbye")) except Exception: goodbye = t("cli.session.goodbye") print(goodbye) return user_msgs = len([m for m in self.conversation_history if m.get("role") == "user"]) tool_calls = len([ m for m in self.conversation_history if m.get("role") == "tool" or m.get("tool_calls")]) elapsed = datetime.now() - self.session_start hours, remainder = divmod(int(elapsed.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) duration_str = t("cli.shared.duration_s", seconds=seconds) if hours > 0: duration_str = t("cli.shared.duration_hm_rest", hours=hours, minutes=minutes, rest=duration_str) elif minutes > 0: duration_str = t("cli.shared.duration_m_rest", minutes=minutes, rest=duration_str) session_title = None if self._session_db: with contextlib.suppress(Exception): session_title = self._session_db.get_session_title(self.session_id) print(t("cli.session.exit_resume_hint")) # Session IDs are profile-constrained: non-default profiles need `-p ` in # the hint ("default"/"custom" use the standard HERMES_HOME). try: from hermes_cli.profiles import get_active_profile_name _active_profile = get_active_profile_name() except Exception: _active_profile = "default" profile_flag = "" if _active_profile in ("default", "custom") else f" -p {_active_profile}" print(f" hermes --resume {self.session_id}{profile_flag}") if session_title: print(f" hermes -c \"{session_title}\"{profile_flag}") print() print(t("cli.session.exit_label_session", session_id=self.session_id)) if session_title: print(t("cli.session.exit_label_title", title=session_title)) print(t("cli.session.exit_label_duration", duration=duration_str)) print(t("cli.session.exit_label_messages", count=msg_count, user=user_msgs, tool_calls=tool_calls))