Inspired by Perplexity Computer: sessions pin/unpin/pinned CLI (#52955)
Perplexity Computer's July update let its agent manage sessions conversationally from any surface — pin, archive, rename, fork — treating session organization as operational infrastructure rather than a GUI nicety. Hermes already has the durable pinned flag in state.db (Desktop sidebar writes it; auto-archive honors it), but no CLI access existed: GUI-only management was a single point of failure and blocked scripting (issue #52955). - hermes sessions pin <id...> / unpin <id...>: set/clear the durable keep flag via SessionDB.set_session_pinned (whole compression lineage, prefix resolution, multi-id, exit 1 on any miss) - hermes sessions pinned [--json]: list all pinned conversations via the include_pinned back-fill (old pins can't fall off a paging window); --json enables backup/restore scripting - docs: user-guide/sessions.md section - tests: 6 tests covering prefix resolution, multi-id partial failure, pinned-only filtering, JSON shape, empty hint
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 <session_id>"
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user