diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 822cd2a09d..8e43f0ffa0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13311,6 +13311,36 @@ def main(): sessions_rename.add_argument("session_id", help="Session ID to rename") sessions_rename.add_argument("title", nargs="+", help="New title for the session") + sessions_pin = sessions_subparsers.add_parser( + "pin", + help="Pin session(s) — durable keep flag, exempt from auto-archive", + description=( + "Set the durable 'keep' flag on one or more sessions. Pinned " + "sessions are exempt from the sessions.auto_archive stale sweep " + "and always appear in listings. The same flag drives the Desktop " + "sidebar's Pinned section — pin from either surface, both see it." + ), + ) + sessions_pin.add_argument( + "session_ids", nargs="+", help="Session ID(s) or unique prefix(es) to pin" + ) + + sessions_unpin = sessions_subparsers.add_parser( + "unpin", help="Remove the pin (durable keep flag) from session(s)" + ) + sessions_unpin.add_argument( + "session_ids", nargs="+", help="Session ID(s) or unique prefix(es) to unpin" + ) + + sessions_pinned = sessions_subparsers.add_parser( + "pinned", help="List pinned sessions" + ) + sessions_pinned.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON (for backup/restore scripting)", + ) + sessions_retitle = sessions_subparsers.add_parser( "retitle-skills", help="Re-title sessions whose auto-title came from a /skill's own text", diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index f0a039ce5e..da8a2bdda0 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -1023,6 +1023,69 @@ def cmd_sessions(args, sessions_parser=None): except ValueError as e: print(f"Error: {e}") + 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 + # always surface in listings; until now only the Desktop sidebar + # could write the flag. Inspired by Perplexity Computer's + # conversational session management (pin/archive from any surface): + # pin state is operational infrastructure, so every surface — GUI, + # TUI, CLI, scripts — needs read/write access to the same store. + pinning = action == "pin" + 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_pinned(resolved, pinning): + verb = "Pinned" if pinning else "Unpinned" + title = db.get_session_title(resolved) + suffix = f" ({title})" if title else "" + print(f"{verb} session '{resolved}'.{suffix}") + else: + print(f"Session '{raw_id}' not found.") + failures += 1 + if failures: + return 1 + + elif action == "pinned": + # List every pinned conversation regardless of age. limit=1 keeps the + # recency page minimal; include_pinned back-fills ALL pinned rows the + # page missed (bounded by pin count, see list_sessions_rich), so old + # pins can't fall off a paging window. + rows = db.list_sessions_rich( + limit=1, include_pinned=True, exclude_sources=_exclude + ) + pinned_rows = [s for s in rows if s.get("pinned")] + if getattr(args, "json", False): + payload = [ + { + "id": s["id"], + "title": s.get("title"), + "source": s.get("source"), + "last_active": s.get("last_active"), + "message_count": s.get("message_count"), + } + for s in pinned_rows + ] + print(_json.dumps(payload, indent=2)) + return + if not pinned_rows: + print( + "No pinned sessions. Pin one with: hermes sessions pin " + ) + return + print(f"{'Title':<32} {'Last Active':<13} {'Src':<9} {'ID'}") + print("─" * 100) + for s in pinned_rows: + title = (s.get("title") or s.get("preview", "") or "—")[:30] + last_active = _relative_time(s.get("last_active")) + print( + f"{title:<32} {last_active:<13} {(s.get('source') or '-'):<9} {s['id']}" + ) + elif action == "retitle-skills": from agent.skill_commands import describe_skill_invocation from agent.title_generator import generate_title diff --git a/tests/hermes_cli/test_sessions_pin.py b/tests/hermes_cli/test_sessions_pin.py new file mode 100644 index 0000000000..aa4c3b0b79 --- /dev/null +++ b/tests/hermes_cli/test_sessions_pin.py @@ -0,0 +1,138 @@ +"""CLI pin / unpin / pinned subcommands (issue #52955). + +Pin state is the durable "keep" flag in state.db that the Desktop sidebar +writes; these tests pin the CLI's read/write access to the SAME store +(SessionDB.set_session_pinned / list_sessions_rich(include_pinned=True)), +not a client-local list. +""" + +import json +import sys + + +class _FakeDB: + def __init__(self, rows=None, known=("20260315_092437_c9a6ff",)): + self.rows = rows or [] + self.known = set(known) + self.pin_calls = [] + self.list_kwargs = None + self.closed = False + + def resolve_session_id(self, session_id): + for k in self.known: + if k.startswith(session_id): + return k + return None + + def set_session_pinned(self, session_id, pinned): + self.pin_calls.append((session_id, pinned)) + return session_id in self.known + + 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 self.rows + + def close(self): + self.closed = True + + +def _run(monkeypatch, capsys, argv_tail, db): + 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_pin_accepts_unique_prefix(monkeypatch, capsys): + db = _FakeDB() + code, out = _run(monkeypatch, capsys, ["pin", "20260315_092437"], db) + assert db.pin_calls == [("20260315_092437_c9a6ff", True)] + assert "Pinned session '20260315_092437_c9a6ff'." in out + assert "(Alpha Work)" in out + assert code == 0 + + +def test_unpin_writes_false(monkeypatch, capsys): + db = _FakeDB() + _code, out = _run(monkeypatch, capsys, ["unpin", "20260315_092437_c9a6ff"], db) + assert db.pin_calls == [("20260315_092437_c9a6ff", False)] + assert "Unpinned session" in out + + +def test_pin_multiple_ids_one_missing(monkeypatch, capsys): + db = _FakeDB(known=("aaa111", "bbb222")) + code, out = _run(monkeypatch, capsys, ["pin", "aaa", "nope", "bbb"], db) + assert ("aaa111", True) in db.pin_calls + assert ("bbb222", True) in db.pin_calls + assert "Session 'nope' not found." in out + assert code == 1 + + +def test_pinned_lists_only_pinned_rows(monkeypatch, capsys): + rows = [ + { + "id": "pinned_one", + "title": "Keep Me", + "source": "cli", + "last_active": 1_700_000_000.0, + "message_count": 3, + "pinned": 1, + }, + { + "id": "recent_unpinned", + "title": "Recent", + "source": "cli", + "last_active": 1_700_000_100.0, + "message_count": 5, + "pinned": 0, + }, + ] + db = _FakeDB(rows=rows) + _code, out = _run(monkeypatch, capsys, ["pinned"], db) + # include_pinned back-fill is what guarantees old pins surface + assert db.list_kwargs["include_pinned"] is True + assert "pinned_one" in out + assert "Keep Me" in out + assert "recent_unpinned" not in out + + +def test_pinned_json_output(monkeypatch, capsys): + rows = [ + { + "id": "pinned_one", + "title": "Keep Me", + "source": "cli", + "last_active": 1_700_000_000.0, + "message_count": 3, + "pinned": 1, + }, + ] + db = _FakeDB(rows=rows) + _code, out = _run(monkeypatch, capsys, ["pinned", "--json"], db) + payload = json.loads(out) + assert payload == [ + { + "id": "pinned_one", + "title": "Keep Me", + "source": "cli", + "last_active": 1_700_000_000.0, + "message_count": 3, + } + ] + + +def test_pinned_empty_hint(monkeypatch, capsys): + db = _FakeDB(rows=[]) + _code, out = _run(monkeypatch, capsys, ["pinned"], db) + assert "No pinned sessions" in out + assert "hermes sessions pin" in out diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 2c8fc4bbb8..0b62c69f71 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -462,6 +462,28 @@ hermes sessions rename 20250305_091523_a1b2c3d4 debugging auth flow If the title is already in use by another session, an error is shown. +### Pin a Session + +Pinning sets a durable "keep" flag: pinned sessions are exempt from the +`sessions.auto_archive` stale sweep and always appear in listings. It is the +same flag the Desktop sidebar's Pinned section uses — pin from either surface +and both see it. + +```bash +# Pin one or more sessions (unique ID prefixes work) +hermes sessions pin 20250305_091523_a1b2c3d4 +hermes sessions pin 20250305 20250306 + +# Remove the pin +hermes sessions unpin 20250305_091523_a1b2c3d4 + +# List pinned sessions +hermes sessions pinned + +# Machine-readable output, e.g. for a nightly backup of your pin set +hermes sessions pinned --json > pinned-sessions.json +``` + ### Prune Old Sessions ```bash