Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
)
Expand Down
38 changes: 37 additions & 1 deletion hermes_cli/sessions_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions tests/hermes_cli/test_sessions_unhide.py
Original file line number Diff line number Diff line change
@@ -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
Loading