diff --git a/cli.py b/cli.py index 1d09a4742a..0fa24f68f6 100644 --- a/cli.py +++ b/cli.py @@ -5056,6 +5056,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") short_uuid = uuid.uuid4().hex[:6] self.session_id = f"{timestamp_str}_{short_uuid}" + self._write_terminal_breadcrumb() # History file for persistent input recall across sessions self._history_file = _hermes_home / ".hermes_history" @@ -9251,6 +9252,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") short_uuid = uuid.uuid4().hex[:6] self.session_id = f"{timestamp_str}_{short_uuid}" + self._write_terminal_breadcrumb() self.conversation_history = [] self._pending_title = None self._resumed = False @@ -12263,6 +12265,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): } _cprint(labels.get(self.tool_progress_mode, "")) + def _write_terminal_breadcrumb(self) -> None: + """Record this terminal's live session for bare ``hermes -c``. + + Called at session start and whenever ``self.session_id`` is + reassigned mid-run (/new, /branch, auto-compression rotation) so a + later bare ``-c`` in THIS terminal resumes THIS conversation's live + tip. Best-effort — never raises, no-op without a terminal identity + or when session.terminal_continue is false. + """ + try: + from hermes_cli.terminal_breadcrumbs import write_breadcrumb + + write_breadcrumb(self.session_id) + except Exception: + pass + def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None: """Move YOLO bypass state from an old session key to a new one. @@ -12586,6 +12604,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): and self.agent.session_id != self.session_id ): self.session_id = self.agent.session_id + self._write_terminal_breadcrumb() self._pending_title = None # Manual /compress replaces conversation_history with a new # compressed handoff for the child session. Persist it from @@ -15660,6 +15679,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ): self._transfer_session_yolo(self.session_id, self.agent.session_id) self.session_id = self.agent.session_id + self._write_terminal_breadcrumb() self._pending_title = None # Get the final response @@ -16044,6 +16064,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): agent._persist_session(messages, conversation_history) if getattr(agent, "session_id", None): self.session_id = agent.session_id + self._write_terminal_breadcrumb() try: if persist_lock is None: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 72bec19fde..1f5acf5baa 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -33,6 +33,15 @@ DEFAULT_CONFIG = { # sessions (no live client) so accumulated agents don't pile up under memory # pressure. Reopening one re-resumes it from disk. 0/null disables. "max_live_sessions": 16, + "session": { + # Per-terminal `hermes -c`: each CLI session drops a breadcrumb file + # under $HERMES_HOME/terminal-sessions/, and a bare + # -c/--continue resumes THIS terminal's session (tmux pane, kitty + # window, wezterm pane, plain tty, ...) instead of the globally + # most-recent one. Set false to restore the old latest-session + # behavior everywhere. + "terminal_continue": True, + }, "agent": { "max_turns": 500, # Inactivity timeout for gateway agent execution (seconds). diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8d87e8e5b5..cea74357d5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2672,17 +2672,31 @@ def cmd_chat(args): print("Use 'hermes sessions list' to see available sessions.") sys.exit(1) else: - # -c with no argument — continue the most recent session - source = "tui" if use_tui else "cli" - last_id = _resolve_last_session(source=source) - if not last_id and source == "tui": - last_id = _resolve_last_session(source="cli") - if last_id: - args.resume = last_id + # -c with no argument — prefer this terminal's own breadcrumb + # (written at session start / rotation) so side-by-side terminals + # each continue their own conversation. Falls back to the + # most-recent session when there is no valid breadcrumb, or when + # session.terminal_continue is false in config.yaml. + try: + from hermes_cli.terminal_breadcrumbs import resolve_breadcrumb_session + + _crumb_id = resolve_breadcrumb_session() + except Exception: + _crumb_id = None + if _crumb_id: + args.resume = _crumb_id else: - kind = "TUI" if use_tui else "CLI" - print(f"No previous {kind} session found to continue.") - sys.exit(1) + # No valid breadcrumb — continue the most recent session + source = "tui" if use_tui else "cli" + last_id = _resolve_last_session(source=source) + if not last_id and source == "tui": + last_id = _resolve_last_session(source="cli") + if last_id: + args.resume = last_id + else: + kind = "TUI" if use_tui else "CLI" + print(f"No previous {kind} session found to continue.") + sys.exit(1) # --resume @claude / --resume @codex: import a foreign session (Claude # Code / Codex CLI) and resume the newly created Hermes session. diff --git a/hermes_cli/terminal_breadcrumbs.py b/hermes_cli/terminal_breadcrumbs.py new file mode 100644 index 0000000000..8ea68af826 --- /dev/null +++ b/hermes_cli/terminal_breadcrumbs.py @@ -0,0 +1,185 @@ +"""Per-terminal session breadcrumbs for ``hermes -c`` / ``--continue``. + +Each CLI session writes a tiny breadcrumb file +``$HERMES_HOME/terminal-sessions/`` containing +``{"session_id": ..., "cwd": ..., "ts": ...}``. A bare ``hermes -c`` then +resumes the session that belongs to THIS terminal (tty / tmux pane / kitty +window / wezterm pane / ...) instead of the globally most-recent session — +so two terminals side by side each continue their own conversation. + +Everything here is strictly best-effort: no function raises, and when no +stable terminal identity can be derived (no tty and no known multiplexer +env var) breadcrumbs are skipped entirely and ``-c`` falls back to the +existing latest-session behavior. Gated by ``session.terminal_continue`` +in config.yaml (default true). +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Optional + +# Multiplexer / terminal-emulator identity env vars, checked in order when +# no real tty path is available (e.g. stdin piped but stdout still a pty +# owned by a known terminal). +_TERMINAL_ENV_VARS = ( + "ZELLIJ_PANE_ID", + "TMUX_PANE", + "KITTY_WINDOW_ID", + "WEZTERM_PANE", + "TERM_SESSION_ID", + "WT_SESSION", +) + +# Breadcrumbs older than this are pruned opportunistically on each write — +# a pane id from a tmux server restarted last month means nothing today. +_STALE_AFTER_SECONDS = 30 * 24 * 60 * 60 + +_SANITIZE_RE = re.compile(r"[^A-Za-z0-9._-]") + + +def _breadcrumbs_dir() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "terminal-sessions" + + +def _sanitize(raw: str) -> str: + """Make an id safe to use as a filename (``/dev/pts/3`` -> ``dev-pts-3``).""" + return _SANITIZE_RE.sub("-", raw.strip().strip("/"))[:120] + + +def get_terminal_id() -> Optional[str]: + """Derive a stable identity for the terminal this process runs in. + + Prefers the real tty device path (stdin, then stdout), else the first + present multiplexer/emulator env var. Returns ``None`` when neither is + available — callers must then skip breadcrumbs entirely. + """ + for fd in (sys.stdin, sys.stdout): + try: + name = os.ttyname(fd.fileno()) + except Exception: + continue + if name: + return f"tty-{_sanitize(name)}" + for var in _TERMINAL_ENV_VARS: + val = os.environ.get(var) + if val: + return f"{var.lower()}-{_sanitize(val)}" + return None + + +def is_enabled() -> bool: + """Config gate: ``session.terminal_continue`` (default true).""" + try: + from hermes_cli.config import load_config + + return bool((load_config().get("session") or {}).get("terminal_continue", True)) + except Exception: + return True + + +def _prune_stale(directory: Path, now: float) -> None: + """Best-effort removal of breadcrumbs older than the staleness window.""" + try: + for entry in directory.iterdir(): + try: + if entry.is_file() and now - entry.stat().st_mtime > _STALE_AFTER_SECONDS: + entry.unlink() + except OSError: + continue + except OSError: + pass + + +def write_breadcrumb(session_id: str, cwd: Optional[str] = None) -> None: + """Record that this terminal's live session is ``session_id``. + + Synchronous, best-effort, never raises. No-op when the feature is + disabled, the session id is empty, or no terminal identity exists. + """ + try: + if not session_id or not is_enabled(): + return + terminal_id = get_terminal_id() + if not terminal_id: + return + directory = _breadcrumbs_dir() + directory.mkdir(parents=True, exist_ok=True) + now = time.time() + payload = { + "session_id": session_id, + "cwd": cwd or os.getcwd(), + "ts": now, + } + tmp = directory / f".{terminal_id}.tmp" + tmp.write_text(json.dumps(payload), encoding="utf-8") + os.replace(tmp, directory / terminal_id) + _prune_stale(directory, now) + except Exception: + pass + + +def read_breadcrumb() -> Optional[dict]: + """Return this terminal's breadcrumb payload, or ``None``. + + Ignores breadcrumbs older than the staleness window. Never raises. + """ + try: + terminal_id = get_terminal_id() + if not terminal_id: + return None + path = _breadcrumbs_dir() / terminal_id + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or not str(data.get("session_id") or "").strip(): + return None + ts = data.get("ts") + if isinstance(ts, (int, float)) and time.time() - ts > _STALE_AFTER_SECONDS: + return None + return data + except Exception: + return None + + +def resolve_breadcrumb_session() -> Optional[str]: + """Resolve a bare ``-c`` for this terminal, or ``None`` to fall back. + + Returns the breadcrumb's session id only when it still exists in the + session DB, projected forward through the compression chain so the + resume lands on the live tip rather than a dead compressed parent + (same projection as ``main._resolve_session_by_name_or_id``). + """ + if not is_enabled(): + return None + crumb = read_breadcrumb() + if not crumb: + return None + session_id = str(crumb.get("session_id") or "").strip() + if not session_id: + return None + db = None + try: + from hermes_state import SessionDB + + db = SessionDB() + if not db.get_session(session_id): + return None # session was deleted — fall back to latest + try: + session_id = db.get_compression_tip(session_id) or session_id + except Exception: + pass + return session_id + except Exception: + return None + finally: + if db is not None: + try: + db.close() + except Exception: + pass diff --git a/tests/hermes_cli/test_terminal_breadcrumbs.py b/tests/hermes_cli/test_terminal_breadcrumbs.py new file mode 100644 index 0000000000..caeeb4cd73 --- /dev/null +++ b/tests/hermes_cli/test_terminal_breadcrumbs.py @@ -0,0 +1,204 @@ +"""Tests for hermes_cli/terminal_breadcrumbs.py — per-terminal ``hermes -c``. + +Covers terminal id derivation (tty vs env vars vs none), breadcrumb +write/read roundtrip under a temp HERMES_HOME, stale-session fallback +(breadcrumb pointing at a deleted session), compression-tip projection, +and the session.terminal_continue config gate. +""" + +import json +import os +import time +from pathlib import Path + +import pytest + +from hermes_cli import terminal_breadcrumbs as tb + + +TERMINAL_ENV_VARS = ( + "ZELLIJ_PANE_ID", + "TMUX_PANE", + "KITTY_WINDOW_ID", + "WEZTERM_PANE", + "TERM_SESSION_ID", + "WT_SESSION", +) + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def no_terminal_env(monkeypatch): + """Strip every terminal-identity env var so tests control identity.""" + for var in TERMINAL_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +def _fake_no_tty(monkeypatch): + monkeypatch.setattr(tb.os, "ttyname", lambda fd: (_ for _ in ()).throw(OSError())) + + +def _fake_tty(monkeypatch, name="/dev/pts/7"): + monkeypatch.setattr(tb.os, "ttyname", lambda fd: name) + + +# ---------------------------------------------------------------- identity + +def test_terminal_id_prefers_tty(monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/7") + monkeypatch.setenv("TMUX_PANE", "%3") + assert tb.get_terminal_id() == "tty-dev-pts-7" + + +def test_terminal_id_env_var_order(monkeypatch, no_terminal_env): + _fake_no_tty(monkeypatch) + monkeypatch.setenv("KITTY_WINDOW_ID", "12") + monkeypatch.setenv("WT_SESSION", "abc-123") + # KITTY_WINDOW_ID comes before WT_SESSION in the preference order + assert tb.get_terminal_id() == "kitty_window_id-12" + + +def test_terminal_id_sanitizes_env_value(monkeypatch, no_terminal_env): + _fake_no_tty(monkeypatch) + monkeypatch.setenv("TMUX_PANE", "%41") + tid = tb.get_terminal_id() + assert tid is not None + assert "/" not in tid and "%" not in tid + + +def test_terminal_id_none_when_no_identity(monkeypatch, no_terminal_env): + _fake_no_tty(monkeypatch) + assert tb.get_terminal_id() is None + + +# ---------------------------------------------------------- write / read + +def test_breadcrumb_roundtrip(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch) + tb.write_breadcrumb("20260815_120000_abc123", cwd="/tmp/project") + crumb = tb.read_breadcrumb() + assert crumb is not None + assert crumb["session_id"] == "20260815_120000_abc123" + assert crumb["cwd"] == "/tmp/project" + assert isinstance(crumb["ts"], float) + files = list((hermes_home / "terminal-sessions").iterdir()) + assert [f.name for f in files] == ["tty-dev-pts-7"] + + +def test_write_skipped_without_terminal_identity(hermes_home, monkeypatch, no_terminal_env): + _fake_no_tty(monkeypatch) + tb.write_breadcrumb("20260815_120000_abc123") + assert not (hermes_home / "terminal-sessions").exists() + assert tb.read_breadcrumb() is None + + +def test_two_terminals_do_not_clobber(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/1") + tb.write_breadcrumb("session-one") + _fake_tty(monkeypatch, "/dev/pts/2") + tb.write_breadcrumb("session-two") + assert tb.read_breadcrumb()["session_id"] == "session-two" + _fake_tty(monkeypatch, "/dev/pts/1") + assert tb.read_breadcrumb()["session_id"] == "session-one" + + +def test_stale_breadcrumb_ignored_and_pruned(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/1") + directory = hermes_home / "terminal-sessions" + directory.mkdir(parents=True) + stale = directory / "tty-dev-pts-1" + stale.write_text( + json.dumps({"session_id": "old", "cwd": "/", "ts": time.time() - 40 * 86400}) + ) + old_mtime = time.time() - 40 * 86400 + os.utime(stale, (old_mtime, old_mtime)) + # read: stale payload rejected + assert tb.read_breadcrumb() is None + # write from another terminal prunes the stale file + _fake_tty(monkeypatch, "/dev/pts/2") + tb.write_breadcrumb("fresh") + assert not stale.exists() + + +def test_corrupt_breadcrumb_returns_none(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/1") + directory = hermes_home / "terminal-sessions" + directory.mkdir(parents=True) + (directory / "tty-dev-pts-1").write_text("not json{") + assert tb.read_breadcrumb() is None + + +# ------------------------------------------------------------- resolution + +def _make_session(home: Path, session_id: str): + from hermes_state import SessionDB + + db = SessionDB() + db.create_session(session_id, "cli") + db.close() + + +def test_resolve_picks_this_terminals_session(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/5") + _make_session(hermes_home, "20260815_100000_aaaaaa") + _make_session(hermes_home, "20260815_110000_bbbbbb") # newer, other terminal + tb.write_breadcrumb("20260815_100000_aaaaaa") + assert tb.resolve_breadcrumb_session() == "20260815_100000_aaaaaa" + + +def test_resolve_falls_back_when_session_deleted(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/5") + _make_session(hermes_home, "20260815_110000_bbbbbb") + tb.write_breadcrumb("20260815_100000_deleted") # never existed / deleted + assert tb.resolve_breadcrumb_session() is None + + +def test_resolve_projects_through_compression_chain(hermes_home, monkeypatch, no_terminal_env): + _fake_tty(monkeypatch, "/dev/pts/5") + _make_session(hermes_home, "20260815_100000_parent") + tb.write_breadcrumb("20260815_100000_parent") + + from hermes_state import SessionDB + + monkeypatch.setattr( + SessionDB, + "get_compression_tip", + lambda self, sid: "20260815_110000_child", + ) + assert tb.resolve_breadcrumb_session() == "20260815_110000_child" + + +# ------------------------------------------------------------ config gate + +def test_config_gate_off_disables_writes_and_resolution( + hermes_home, monkeypatch, no_terminal_env +): + _fake_tty(monkeypatch, "/dev/pts/9") + import hermes_cli.config as config_mod + + monkeypatch.setattr( + config_mod, "load_config", lambda: {"session": {"terminal_continue": False}} + ) + tb.write_breadcrumb("20260815_120000_abc123") + assert not (hermes_home / "terminal-sessions").exists() + # Even with a pre-existing breadcrumb, resolution must decline + monkeypatch.setattr(config_mod, "load_config", lambda: {}) + tb.write_breadcrumb("20260815_120000_abc123") + monkeypatch.setattr( + config_mod, "load_config", lambda: {"session": {"terminal_continue": False}} + ) + assert tb.resolve_breadcrumb_session() is None + + +def test_config_gate_default_is_enabled(monkeypatch): + import hermes_cli.config as config_mod + + monkeypatch.setattr(config_mod, "load_config", lambda: {}) + assert tb.is_enabled() is True diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 705e189c80..2c8fc4bbb8 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -115,6 +115,10 @@ hermes chat -c This looks up the most recent `cli` session from the SQLite database and loads its full conversation history. +#### Per-Terminal Continue + +A bare `-c` is terminal-aware: each CLI session drops a small breadcrumb file under `~/.hermes/terminal-sessions/` keyed by the terminal it runs in (tty device, tmux pane, kitty window, wezterm pane, Zellij pane, Windows Terminal session, ...). When you run `hermes -c` again in the *same* terminal, Hermes resumes that terminal's own session — so two panes side by side each continue their own conversation instead of both grabbing the globally most-recent one. If there's no breadcrumb for the terminal (first use, deleted session, or a stale breadcrumb older than 30 days), `-c` falls back to the most-recent-session behavior. `-c "name"` and `--resume` are unaffected. Disable with `session.terminal_continue: false` in `config.yaml`. + ### Resume by Name If you've given a session a title (see [Session Naming](#session-naming) below), you can resume it by name: