diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 6d8d0bae5f..82a39d14a3 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -3040,6 +3040,18 @@ def _carry_session_state_to_child(agent: Any, old_session_id: str, old_title: An agent._session_db.set_session_title_source(agent.session_id, _src) +def _compression_child_source(agent: Any, parent_session_id: str) -> str: + """The parent row's persisted source: a compression child is the same conversation, so a ``--source tool``, + ``oneshot`` or inherited ``kanban`` label must not degrade to the bare ``agent.platform`` (#112550).""" + parent = None + with contextlib.suppress(Exception): + parent = agent._session_db.get_session(parent_session_id) + if parent and parent.get("source"): + return parent["source"] + from run_agent import _session_source_for_agent # late: run_agent imports this module + return _session_source_for_agent(getattr(agent, "platform", None)) + + def _publish_rotated_compaction( agent: Any, messages: list, compressed: list, *, new_system_prompt: str, lease: _CompressionLease, old_session_id: str, compressed_user_turn_outcome: str, @@ -3078,7 +3090,7 @@ def _publish_rotated_compaction( from agent.context_compressor import _DB_PERSISTED_MARKER agent._session_db.publish_compression_child( parent_session_id=old_session_id, child_session_id=new_session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=agent.model, + source=_compression_child_source(agent, old_session_id), model=agent.model, model_config=agent._session_init_model_config, system_prompt=new_system_prompt, messages=compressed, cwd=getattr(agent, "working_directory", None), profile_name=_profile_for_child, compression_lock_holder=lease.holder, require_compression_lease=lease.holder is not None, diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 79ed487ea2..3fd19bf20b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -43,12 +43,13 @@ import { $sessionTiles, $workingSessionIds, getRecentlySettledSessionIds } from import { refreshCronJobs as refreshCronJobsStore } from '../../cron/cron-actions' // The recents list is local-only: cron rows have their own section, kanban -// dispatcher workers are read on the board, and each messaging platform +// dispatcher workers are read on the board, finite one-shot runs (`hermes -z`, +// `chat -q`) are not conversations, and each messaging platform // (telegram, discord, …) is fetched separately into its own self-managed // sidebar section (refreshMessagingSessions). Excluding them here keeps // "Load more" paging through interactive local chats instead of // interleaving gateway threads that bury them. -const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'kanban', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] +const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'kanban', 'oneshot', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] // The messaging slice is the inverse: drop cron + every local source so only // external-platform conversations remain, then split per platform in the UI. const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] diff --git a/apps/desktop/src/lib/session-source.ts b/apps/desktop/src/lib/session-source.ts index 38fc60cded..d04904e595 100644 --- a/apps/desktop/src/lib/session-source.ts +++ b/apps/desktop/src/lib/session-source.ts @@ -13,6 +13,7 @@ const SOURCE_LABELS: Record = { local: 'Local', matrix: 'Matrix', mattermost: 'Mattermost', + oneshot: 'One-shot', photon: 'Photon', qqbot: 'QQ', signal: 'Signal', @@ -43,7 +44,7 @@ const SOURCE_ALIASES: Record = { // platform. A handoff *from* one of these isn't a platform origin worth a badge. // Exported so the recents fetch can keep these in the main list while the // messaging fetch excludes them. -export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'kanban', 'local', 'tui'] +export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'kanban', 'local', 'oneshot', 'tui'] const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS) // External messaging platforms that each get their own self-managed sidebar diff --git a/hermes_cli/cli_session_mixin.py b/hermes_cli/cli_session_mixin.py index 32c49d44d8..31e3332c3b 100644 --- a/hermes_cli/cli_session_mixin.py +++ b/hermes_cli/cli_session_mixin.py @@ -316,11 +316,12 @@ class CLISessionMixin: return [] try: from hermes_cli.session_listing import query_session_listing + from hermes_state_sessions import INTERNAL_LISTING_SOURCES return query_session_listing( self._session_db, source="cli", current_session_id=self.session_id, include_all_sources=False, include_unnamed=True, limit=limit, - exclude_sources=["kanban", "tool"]) + exclude_sources=list(INTERNAL_LISTING_SOURCES)) except Exception: return [] diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index eb8beb1821..89c598ff88 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -599,17 +599,19 @@ def _sessions_list(_engine: HermesConsoleEngine, args: list[str]) -> str: ns = _parse("sessions list", args, (("--limit",), dict(type=int, default=20))) if ns.limit < 1 or ns.limit > 200: raise ConsoleCommandError("sessions list --limit must be between 1 and 200") + from hermes_state_sessions import INTERNAL_LISTING_SOURCES with _session_db() as db: sessions = db.list_sessions_rich( - exclude_sources=["kanban", "tool"], limit=ns.limit, order_by_last_active=True) + exclude_sources=list(INTERNAL_LISTING_SOURCES), limit=ns.limit, order_by_last_active=True) return _format_sessions(sessions) def _sessions_stats(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "sessions stats") + from hermes_state_sessions import INTERNAL_LISTING_SOURCES with _session_db() as db: total = db.session_count() - listable = db.session_count(exclude_children=True, exclude_sources=["kanban", "tool"]) + listable = db.session_count(exclude_children=True, exclude_sources=list(INTERNAL_LISTING_SOURCES)) lines = [ f"Total sessions: {total}", f"Listable sessions: {listable}", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9e713ba3ab..d0039c1532 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1254,6 +1254,10 @@ def _resolve_last_session(source: str = "cli") -> Optional[str]: global MRU. Falls back to the unscoped MRU when no session matches the current workspace, preserving the old behaviour for fresh directories. """ + # A finite `hermes -z`/`chat -q` run is CLI history too: `hermes -z … --resume latest` chains on it. + if source == "cli": + from run_agent import CLI_FAMILY_SOURCES + source = sorted(CLI_FAMILY_SOURCES) with _session_db() as db: ws_key = _resolve_workspace_key() if ws_key: diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index 7e2896b247..3798669760 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -8,7 +8,7 @@ import re import sqlite3 import time from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union from agent.session_activity import ( ActivityProvenance, bound_activity_description, normalize_activity_provenance, @@ -170,6 +170,11 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: # Lifecycle statuses surfaced by session pickers; classified from the final # message row ONLY so it stays O(1) per session. +# Sessions that are not human conversations (kanban workers, third-party tool integrations, finite one-shot +# runs): every human picker — TUI/Desktop session lists, ``/sessions`` in the CLI, ``sessions list`` in the +# console — excludes them. A deny-list, so new interactive platforms surface automatically. +INTERNAL_LISTING_SOURCES = ("kanban", "tool", "oneshot") + SESSION_STATUS_COMPLETE = "complete" SESSION_STATUS_INTERRUPTED = "interrupted" SESSION_STATUS_ERROR = "error" @@ -1378,15 +1383,17 @@ class SessionSessionsMixin: return list(reversed(chain)) or [session_id] def search_sessions( - self, source: str = None, limit: int = 20, offset: int = 0, workspace_key: str = None, + self, source: Union[str, Sequence[str], None] = None, limit: int = 20, offset: int = 0, + workspace_key: str = None, ) -> List[Dict[str, Any]]: """Sessions MRU-first with a computed ``last_active``; ``workspace_key`` scopes to one workspace - so ``hermes -c``/``--resume`` picks its last session.""" + so ``hermes -c``/``--resume`` picks its last session. ``source`` may be one label or several.""" where_clauses = [] params: list = [] if source: - where_clauses.append("s.source = ?") - params.append(source) + sources = [source] if isinstance(source, str) else list(source) + where_clauses.append(f"s.source IN ({','.join('?' * len(sources))})") + params.extend(sources) if workspace_key: ws_clause, ws_params = _workspace_key_clause(workspace_key) where_clauses.append(ws_clause) diff --git a/run_agent.py b/run_agent.py index dc954fe503..43ab361f07 100644 --- a/run_agent.py +++ b/run_agent.py @@ -35,7 +35,7 @@ def _launch_cwd_for_session(source: str) -> Optional[str]: Only local CLI sessions record one: gateway/cron/remote backends (non-"local" ``TERMINAL_ENV``) have no stable host cwd for the agent's tools. """ - if source != "cli" or (os.environ.get("TERMINAL_ENV") or "local").strip().lower() not in ("", "local"): + if source not in CLI_FAMILY_SOURCES or (os.environ.get("TERMINAL_ENV") or "local").strip().lower() not in ("", "local"): return None try: return os.getcwd() @@ -50,6 +50,11 @@ def _launch_cwd_for_session(source: str) -> Optional[str]: # Automation sources (kanban, tool, cron, a2a, ...) are inherited on purpose. _UI_TRANSPORT_SOURCES = frozenset({"tui", "desktop"}) +# Finite non-interactive CLI runs (``hermes chat -q``/``--oneshot``, ``hermes -z``) get their own source so human +# pickers hide them without title/cwd heuristics; ``hermes -c`` still treats them as CLI history. +ONESHOT_SOURCE = "oneshot" +CLI_FAMILY_SOURCES = frozenset({"cli", ONESHOT_SOURCE}) + def _session_source_for_agent(platform: Optional[str]) -> str: try: @@ -57,9 +62,12 @@ def _session_source_for_agent(platform: Optional[str]) -> str: except Exception: get_session_env = os.environ.get source = str(get_session_env("HERMES_SESSION_SOURCE", "") or "").strip() - if (source in _UI_TRANSPORT_SOURCES and get_session_env("HERMES_SINGLE_QUERY_SESSION", "") == "1" - and get_session_env("HERMES_SESSION_SOURCE_EXPLICIT", "") != "1"): + single_query = get_session_env("HERMES_SINGLE_QUERY_SESSION", "") == "1" + explicit = get_session_env("HERMES_SESSION_SOURCE_EXPLICIT", "") == "1" + if single_query and not explicit and source in _UI_TRANSPORT_SOURCES: source = "" + if single_query and not source and (platform or "cli") == "cli": + return ONESHOT_SOURCE return source or platform or "cli" diff --git a/tests/agent/test_compression_child_source.py b/tests/agent/test_compression_child_source.py new file mode 100644 index 0000000000..9aabfc413f --- /dev/null +++ b/tests/agent/test_compression_child_source.py @@ -0,0 +1,45 @@ +"""A compression child is the same conversation as its parent: it keeps the parent's persisted source +(``--source tool``, ``oneshot``, an inherited ``kanban``) instead of degrading to ``agent.platform`` (#112550).""" + +import types + +import pytest + +from agent import conversation_compression as cc +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + database = SessionDB(tmp_path / "state.db") + try: + yield database + finally: + database.close() + + +def _agent(db, session_id): + return types.SimpleNamespace( + _session_db=db, session_id=session_id, platform="cli", model="m", _session_init_model_config=None, + working_directory=None, _memory_manager=None, context_compressor=types.SimpleNamespace(), + _flush_messages_to_session_db=lambda *a, **k: None, _persist_user_message_idx=None, + _session_messages=None, _gateway_session_key=None, _cached_system_prompt="sys", + ) + + +@pytest.mark.parametrize("parent_source", ["tool", "oneshot", "kanban"]) +def test_compression_child_keeps_parent_source(db, parent_source): + db.create_session("parent", source=parent_source) + db.append_message("parent", "user", "hello") + db.append_message("parent", "assistant", "world") + agent = _agent(db, "parent") + + cc._publish_rotated_compaction( + agent, [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}], + [{"role": "user", "content": "[handoff]"}], new_system_prompt="sys", + lease=types.SimpleNamespace(holder=None, ttl=60.0, watermark=None), + old_session_id="parent", compressed_user_turn_outcome="none", + ) + + assert agent.session_id != "parent" + assert db.get_session(agent.session_id)["source"] == parent_source diff --git a/tests/agent/test_session_source.py b/tests/agent/test_session_source.py index d694b5d3ee..333ff07509 100644 --- a/tests/agent/test_session_source.py +++ b/tests/agent/test_session_source.py @@ -31,14 +31,22 @@ def test_session_source_falls_back_to_platform(monkeypatch): -@pytest.mark.parametrize("inherited", ["tui", "desktop"]) -def test_oneshot_child_drops_inherited_ui_transport_source(monkeypatch, inherited): - """A finite `hermes chat -q` spawned from a TUI/Desktop session inherits the transport's - HERMES_SESSION_SOURCE but is not that conversation: it keeps its own platform label (#112550).""" +@pytest.mark.parametrize("inherited", ["", "tui", "desktop"]) +def test_oneshot_run_gets_distinct_source(monkeypatch, inherited): + """A finite `hermes chat -q` / `hermes -z` run is tagged `oneshot`, whether launched from a plain shell + or spawned inside a TUI/Desktop session (whose transport label it inherits but is not) (#112550).""" monkeypatch.setenv("HERMES_SESSION_SOURCE", inherited) monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1") - assert _session_source_for_agent("cli") == "cli" + assert _session_source_for_agent("cli") == "oneshot" + + +def test_oneshot_marker_does_not_relabel_subagents(monkeypatch): + """Delegate children inside a one-shot process share its env but keep their own platform.""" + monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False) + monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1") + + assert _session_source_for_agent("subagent") == "subagent" @pytest.mark.parametrize("inherited", ["kanban", "tool", "a2a"]) diff --git a/tests/hermes_cli/test_resolve_last_session.py b/tests/hermes_cli/test_resolve_last_session.py index 2adabb52e2..dd8d5e2374 100644 --- a/tests/hermes_cli/test_resolve_last_session.py +++ b/tests/hermes_cli/test_resolve_last_session.py @@ -133,3 +133,32 @@ def test_resolve_last_session_real_db_prefers_workspace(monkeypatch, tmp_path): ) monkeypatch.setattr("hermes_state.SessionDB", lambda **kw: real_db(db_path=state_db, **kw)) assert _resolve_last_session("cli") == "repo_a" + + +def test_resolve_last_session_cli_continues_a_oneshot(monkeypatch, tmp_path): + """`hermes -z … --resume latest` / `hermes -c` chain on the previous one-shot: its distinct `oneshot` + source hides it from pickers but it is still CLI history (#112550).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import hermes_state + from pathlib import Path + + state_db = Path(tmp_path / "state.db") + real_db = hermes_state.SessionDB + db = real_db(db_path=state_db) + try: + db.create_session("interactive", source="cli") + db.create_session("oneshot_run", source="oneshot") + db.create_session("tui_chat", source="tui") + with db._lock: + for sid, started in (("interactive", 100.0), ("oneshot_run", 200.0), ("tui_chat", 300.0)): + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (started, sid)) + db._conn.commit() + finally: + db.close() + + monkeypatch.setattr("hermes_cli.main._resolve_workspace_key", lambda: None) + monkeypatch.setattr("hermes_state.SessionDB", lambda **kw: real_db(db_path=state_db, **kw)) + assert _resolve_last_session("cli") == "oneshot_run" + assert _resolve_last_session("tui") == "tui_chat" diff --git a/tests/tui_gateway/test_session_hidden_rpc.py b/tests/tui_gateway/test_session_hidden_rpc.py index 2e197f67f5..a44a5cf455 100644 --- a/tests/tui_gateway/test_session_hidden_rpc.py +++ b/tests/tui_gateway/test_session_hidden_rpc.py @@ -69,3 +69,16 @@ def test_session_list_include_hidden(db): all_rows = _call("session.list", {"include_hidden": True})["result"]["sessions"] assert {s["id"] for s in all_rows} == {"plain-chat", "bot-chat"} + + +@pytest.mark.parametrize("source", ["oneshot", "kanban", "tool"]) +def test_session_list_hides_internal_sources(db, source): + """Finite one-shot runs (`hermes -z`, `chat -q`) and other non-conversation rows never reach the + human picker; interactive rows stay (#112550).""" + _seed(db, "plain-chat") + db.create_session("internal-run", source=source) + db._conn.execute("UPDATE sessions SET message_count = 1 WHERE id = ?", ("internal-run",)) + db._conn.commit() + + rows = _call("session.list", {})["result"]["sessions"] + assert {s["id"] for s in rows} == {"plain-chat"} diff --git a/tui_gateway/methods_projects.py b/tui_gateway/methods_projects.py index 09bc1c2d1c..96bcd9cf36 100644 --- a/tui_gateway/methods_projects.py +++ b/tui_gateway/methods_projects.py @@ -314,7 +314,7 @@ def _discover_repos_payload( # Not user conversations; subagent/compression children are dropped by include_children=False. -_PROJECT_TREE_EXCLUDED_SOURCES = ["cron", "kanban"] +_PROJECT_TREE_EXCLUDED_SOURCES = ["cron", "kanban", "oneshot"] def _project_tree_row(r: dict) -> dict: diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 652697b7dc..d916aac71d 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -123,8 +123,10 @@ def _session_row_summary(row: dict, *, tip_row: dict | None = None, resolved_id= "source": row.get("source") or ""} -# Hidden from human listings (sub-agent runs, kanban workers); a deny-list so new platforms surface automatically. -_LISTING_DENY_SOURCES = frozenset({"kanban", "tool"}) +from hermes_state_sessions import INTERNAL_LISTING_SOURCES + +# Hidden from human listings (kanban workers, tool integrations, one-shot runs); see INTERNAL_LISTING_SOURCES. +_LISTING_DENY_SOURCES = frozenset(INTERNAL_LISTING_SOURCES) def _denied_source(row: dict) -> bool: diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index b750aafbb9..618bbe45d9 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -91,6 +91,7 @@ const SOURCE_CONFIG: Record = sms: { icon: MessageCircle, color: "text-success" }, cron: { icon: Clock, color: "text-warning" }, tool: { icon: Play, color: "text-warning" }, + oneshot: { icon: Terminal, color: "text-warning" }, api_server: { icon: Globe, color: "text-muted-foreground" }, acp: { icon: Database, color: "text-muted-foreground" }, hermes_flow: { icon: Play, color: "text-warning" }, @@ -101,6 +102,7 @@ const SOURCE_CONFIG: Record = const AUTOMATION_SESSION_SOURCES = [ "cron", "tool", + "oneshot", "api_server", "acp", "hermes_flow", diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index c2d5ff89f1..381bf81fd8 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -131,7 +131,7 @@ Common options: | `--ignore-user-config` | Ignore `~/.hermes/config.yaml` and use built-in defaults. Credentials in `.env` are still loaded. Useful for isolated CI runs, reproducible bug reports, and third-party integrations. | | `--ignore-rules` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, persistent memory, and preloaded skills. Combine with `--ignore-user-config` for a fully isolated run. | | `--safe-mode` | Troubleshooting mode: disable ALL customizations — user config, rules/memory injection, plugins, shell hooks, and MCP servers (implies `--ignore-user-config` and `--ignore-rules`). Use to isolate whether a problem comes from your setup or from Hermes itself. | -| `--source ` | Session source tag for filtering (default: `cli`). Use `tool` for third-party integrations that should not appear in user session lists. | +| `--source ` | Session source tag for filtering (default: `cli`; one-shot runs default to `oneshot`, which pickers hide). Use `tool` for third-party integrations that should not appear in user session lists. An explicit `--source` is always stored as given, even for a one-shot run launched from inside a TUI or Desktop session. | | `--max-turns ` | Maximum tool-calling iterations per conversation turn (default: 500, or `agent.max_turns` in config). | Examples: diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 2f7a7bbe76..4426842672 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -74,7 +74,8 @@ Each session is tagged with its source platform: | Source | Description | |--------|-------------| -| `cli` | Interactive CLI (`hermes` or `hermes chat`), and one-shot runs (`hermes chat -q`, `hermes -z`). A one-shot child launched from inside a TUI or Desktop session is still tagged `cli`, not `tui`/`desktop` — it is not that conversation, so it never shows up in the TUI/WebUI picker as a resumable chat. Pass `--source tool` to keep one-shot integration runs out of session lists entirely. | +| `cli` | Interactive CLI (`hermes` or `hermes chat`) | +| `oneshot` | Finite non-interactive runs: `hermes chat --oneshot -q`, `-Q`, `hermes -z`, and `-q` on non-TTY stdio. Hidden from the TUI, Desktop and dashboard session pickers (like `kanban` and `tool`), even when launched from inside a TUI or Desktop session — the run inherits that transport's environment but is not that conversation. Still counts as CLI history: `hermes -c` / `--resume latest` continue the last one-shot, and `hermes sessions list` shows it. An explicit `--source ` always wins (`hermes chat -q --source tui` is stored as `tui`). | | `telegram` | Telegram messenger | | `discord` | Discord server/DM | | `slack` | Slack workspace | @@ -96,6 +97,10 @@ Each session is tagged with its source platform: | `acp` | ACP editor integration | | `cron` | Scheduled cron jobs | | `batch` | Batch processing runs | +| `kanban` | Kanban dispatcher workers (read on the board, hidden from session pickers) | +| `tool` | Third-party integrations (`--source tool`), hidden from session pickers | + +A session compressed mid-conversation continues under the same source: the compression child of a `--source tool` or `oneshot` run is tagged the same way, so it inherits the same picker visibility. ## CLI Session Resume