feat: per-terminal --continue via terminal breadcrumbs

This commit is contained in:
Teknium
2026-08-15 17:56:31 -07:00
parent 04c61f2949
commit d6f02e3496
6 changed files with 447 additions and 10 deletions
+21
View File
@@ -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:
+9
View File
@@ -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/<terminal-id>, 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).
+24 -10
View File
@@ -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.
+185
View File
@@ -0,0 +1,185 @@
"""Per-terminal session breadcrumbs for ``hermes -c`` / ``--continue``.
Each CLI session writes a tiny breadcrumb file
``$HERMES_HOME/terminal-sessions/<terminal-id>`` 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
@@ -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
+4
View File
@@ -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: