diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d4e6408d94b71..f0013d00875be 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -14056,6 +14056,12 @@ def cmd_computer_use(args): sessions_list.add_argument( "--limit", type=int, default=20, help="Max sessions to show" ) + sessions_list.add_argument( + "--include-hidden", + action="store_true", + help="Include sessions with the durable Hidden flag (plugin-owned rows, " + "and any conversation wrongly hidden — see `hermes sessions unhide`)", + ) sessions_list.add_argument( "--workspace", metavar="NEEDLE", @@ -14479,6 +14485,23 @@ def _add_session_filter_args(p, default_older_help): "session_ids", nargs="+", help="Session ID(s) or unique prefix(es) to unpin" ) + sessions_unhide = sessions_subparsers.add_parser( + "unhide", + help="Unhide session(s) — clear the durable Hidden flag", + description=( + "Clear the durable 'hidden' flag so the session appears in the " + "global Sessions listing again. A hidden session stays fully " + "resumable by the surface that owns it; this restores visibility, " + "never content. The whole compression lineage is unhidden as a " + "unit, mirroring pin/unpin. Use `hermes sessions list` on a " + "backend that knows about hidden rows, or query state.db, to " + "discover hidden ids first." + ), + ) + sessions_unhide.add_argument( + "session_ids", nargs="+", help="Session ID(s) or unique prefix(es) to unhide" + ) + sessions_pinned = sessions_subparsers.add_parser( "pinned", help="List pinned sessions" ) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index 3cc69f44bcecf..79f281c252b4c 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -330,7 +330,10 @@ def _recovery_progress(info): from hermes_state import workspace_key as _ws_key sessions = db.list_sessions_rich( - source=args.source, exclude_sources=_exclude, limit=args.limit + source=args.source, + exclude_sources=_exclude, + limit=args.limit, + include_hidden=getattr(args, "include_hidden", False), ) # Workspace filter: match a session by its workspace key (git repo @@ -1082,6 +1085,39 @@ def _export_one(session_id: str, *, include_lineage: bool = False): print(f"Error: {e}") return 1 + elif action == "unhide": + # The `hidden` flag is a durable "don't show in the global Sessions + # sidebar" marker set by surfaces that own their sessions (Bot Mode + # plumbing rows, plugins, the REST PATCH on api_server). Until now + # there was no way back out: a stale or wrongly-adopted pointer left + # an ordinary conversation hidden from every listing with no CLI, UI + # or documented recovery short of raw SQL on state.db. Unhide is the + # recovery affordance for that bug class — the DB setter flips the + # whole compression lineage as a unit (set_session_hidden), so one + # id per conversation is enough. + failures = 0 + for raw_id in args.session_ids: + resolved = db.resolve_session_id(raw_id) + if not resolved: + print(f"Session '{raw_id}' not found.") + failures += 1 + continue + if db.set_session_hidden(resolved, False): + title = db.get_session_title(resolved) + suffix = f" ({title})" if title else "" + print(f"Unhidden session '{resolved}'.{suffix}") + else: + # resolve_session_id() already proved the row exists, so a + # False here is the setter's "no rows changed" return — the + # session (and its whole lineage) was already visible. + # Reporting "not found" would be wrong AND would bump + # failures toward exit 1, making an idempotent second unhide + # of the same id look like a failure. set_session_hidden + # contract: True only when at least one row changed. + print(f"Session '{resolved}' is already visible — nothing to unhide.") + if failures: + return 1 + elif action in ("pin", "unpin"): # CLI surface for the durable "keep" flag (issue #52955). Pinned # sessions are exempt from the sessions.auto_archive stale sweep and diff --git a/tests/hermes_cli/test_sessions_unhide.py b/tests/hermes_cli/test_sessions_unhide.py new file mode 100644 index 0000000000000..9f81c1bf2cd59 --- /dev/null +++ b/tests/hermes_cli/test_sessions_unhide.py @@ -0,0 +1,116 @@ +"""CLI unhide subcommand — recovery affordance for the durable hidden flag. + +Hiding is a legitimate write path (plugin-owned sessions, the REST PATCH on +api_server), but a stale or wrongly-adopted pointer can hide an ordinary +user conversation with no documented way back. These tests pin the CLI's +access to the SAME store the setters use (SessionDB.set_session_hidden(False)), +not a client-local list — the exact precedent of the pin/unpin tests. +""" + + +class _FakeDB: + def __init__(self, known=("20260315_092437_c9a6ff",), hidden=()): + self.known = set(known) + self.hidden = set(hidden) + self.hide_calls = [] + self.list_kwargs = None + + def resolve_session_id(self, session_id): + for k in self.known: + if k.startswith(session_id): + return k + return None + + def set_session_hidden(self, session_id, hidden): + self.hide_calls.append((session_id, hidden)) + # SessionDB.set_session_hidden contract: True only when at least one + # row actually changed. Unhiding an already-visible session (or its + # already-visible lineage) is a no-op that returns False. + was_hidden = session_id in self.hidden + if hidden: + self.hidden.add(session_id) + else: + self.hidden.discard(session_id) + return was_hidden != hidden + + def get_session_title(self, session_id): + return "Alpha Work" if session_id in self.known else None + + def list_sessions_rich(self, **kwargs): + self.list_kwargs = kwargs + return [] + + def close(self): + pass + + +def _run(monkeypatch, capsys, argv_tail, db): + import sys + + import hermes_cli.main as main_mod + import hermes_state + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: db) + monkeypatch.setattr(sys, "argv", ["hermes", "sessions", *argv_tail]) + try: + main_mod.main() + code = 0 + except SystemExit as e: # non-zero exits propagate through main() + code = e.code or 0 + return code, capsys.readouterr().out + + +def test_unhide_accepts_unique_prefix(monkeypatch, capsys): + db = _FakeDB(hidden=("20260315_092437_c9a6ff",)) + code, out = _run(monkeypatch, capsys, ["unhide", "20260315_092437"], db) + assert db.hide_calls == [("20260315_092437_c9a6ff", False)] + assert "Unhidden session '20260315_092437_c9a6ff'." in out + assert "(Alpha Work)" in out + assert code == 0 + + +def test_unhide_multiple_ids_one_missing(monkeypatch, capsys): + db = _FakeDB(known=("aaa111", "bbb222"), hidden=("aaa111", "bbb222")) + code, out = _run(monkeypatch, capsys, ["unhide", "aaa", "nope", "bbb"], db) + assert ("aaa111", False) in db.hide_calls + assert ("bbb222", False) in db.hide_calls + assert "Session 'nope' not found." in out + assert code == 1 + + +def test_unhide_already_visible_is_idempotent_success(monkeypatch, capsys): + """The setter's False return means 'no row changed', not 'not found'. + + resolve_session_id() proved the row exists before the setter ran, so an + idempotent second unhide of an already-visible session must report that + state accurately and must NOT count as a failure (exit 1) — recovery + scripts re-running unhide would otherwise read success as failure. + """ + db = _FakeDB() # session exists but is NOT hidden + code, out = _run(monkeypatch, capsys, ["unhide", "20260315_092437"], db) + assert db.hide_calls == [("20260315_092437_c9a6ff", False)] + assert "not found" not in out.lower() + assert "already visible" in out + assert code == 0 + + +def test_unhide_mixed_hidden_and_visible_counts_exit_zero(monkeypatch, capsys): + db = _FakeDB(known=("aaa111", "bbb222"), hidden=("aaa111",)) + code, out = _run(monkeypatch, capsys, ["unhide", "aaa", "bbb"], db) + assert "Unhidden session 'aaa111'." in out + assert "already visible" in out + assert code == 0 + + +def test_list_include_hidden_flag_reaches_db(monkeypatch, capsys): + db = _FakeDB() + _code, _out = _run(monkeypatch, capsys, ["list", "--include-hidden"], db) + assert db.list_kwargs is not None + assert db.list_kwargs["include_hidden"] is True + + +def test_list_default_excludes_hidden(monkeypatch, capsys): + db = _FakeDB() + _code, _out = _run(monkeypatch, capsys, ["list"], db) + assert db.list_kwargs is not None + assert db.list_kwargs["include_hidden"] is False