diff --git a/cli.py b/cli.py index fa32ae911..8b5bfea2d 100644 --- a/cli.py +++ b/cli.py @@ -18,6 +18,8 @@ import os import shutil import sys import json +import re +import base64 import atexit import tempfile import time @@ -78,6 +80,42 @@ _project_env = Path(__file__).parent / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) +_REASONING_TAGS = ( + "REASONING_SCRATCHPAD", + "think", + "reasoning", + "THINKING", + "thinking", +) + + +def _strip_reasoning_tags(text: str) -> str: + cleaned = text + for tag in _REASONING_TAGS: + cleaned = re.sub(rf"<{tag}>.*?\s*", "", cleaned, flags=re.DOTALL) + cleaned = re.sub(rf"<{tag}>.*$", "", cleaned, flags=re.DOTALL) + return cleaned.strip() + + +def _assistant_content_as_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + return "\n".join(p for p in parts if p) + return str(content) + + +def _assistant_copy_text(content: Any) -> str: + return _strip_reasoning_tags(_assistant_content_as_text(content)) + + # ============================================================================= # Configuration Loading # ============================================================================= @@ -2659,21 +2697,6 @@ class HermesCLI: MAX_ASST_LEN = 200 # truncate assistant text MAX_ASST_LINES = 3 # max lines of assistant text - def _strip_reasoning(text: str) -> str: - """Remove ... blocks - from displayed text (reasoning model internal thoughts).""" - import re - cleaned = re.sub( - r".*?\s*", - "", text, flags=re.DOTALL, - ) - # Also strip unclosed reasoning tags at the end - cleaned = re.sub( - r".*$", - "", cleaned, flags=re.DOTALL, - ) - return cleaned.strip() - # Collect displayable entries (skip system, tool-result messages) entries = [] # list of (role, display_text) for msg in self.conversation_history: @@ -2703,7 +2726,7 @@ class HermesCLI: elif role == "assistant": text = "" if content is None else str(content) - text = _strip_reasoning(text) + text = _strip_reasoning_tags(text) parts = [] if text: lines = text.splitlines() @@ -2935,6 +2958,26 @@ class HermesCLI: killed = process_registry.kill_all() print(f" ✅ Stopped {killed} process(es).") + def _handle_agents_command(self): + """Handle /agents — show background processes and agent status.""" + from tools.process_registry import format_uptime_short, process_registry + + processes = process_registry.list_sessions() + running = [p for p in processes if p.get("status") == "running"] + finished = [p for p in processes if p.get("status") != "running"] + + _cprint(f" Running processes: {len(running)}") + for p in running: + cmd = p.get("command", "")[:80] + up = format_uptime_short(p.get("uptime_seconds", 0)) + _cprint(f" {p.get('session_id', '?')} · {up} · {cmd}") + + if finished: + _cprint(f" Recently finished: {len(finished)}") + + agent_running = getattr(self, "_agent_running", False) + _cprint(f" Agent: {'running' if agent_running else 'idle'}") + def _handle_paste_command(self): """Handle /paste — explicitly check clipboard for an image. @@ -2952,6 +2995,61 @@ class HermesCLI: else: _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") + def _write_osc52_clipboard(self, text: str) -> None: + """Copy *text* to terminal clipboard via OSC 52.""" + payload = base64.b64encode(text.encode("utf-8")).decode("ascii") + seq = f"\x1b]52;c;{payload}\x07" + out = getattr(self, "_app", None) + output = getattr(out, "output", None) if out else None + if output and hasattr(output, "write_raw"): + output.write_raw(seq) + output.flush() + return + if output and hasattr(output, "write"): + output.write(seq) + output.flush() + return + sys.stdout.write(seq) + sys.stdout.flush() + + def _handle_copy_command(self, cmd_original: str) -> None: + """Handle /copy [number] — copy assistant output to clipboard.""" + parts = cmd_original.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + assistant = [m for m in self.conversation_history if m.get("role") == "assistant"] + if not assistant: + _cprint(" Nothing to copy yet.") + return + + if arg: + try: + idx = int(arg) - 1 + except ValueError: + _cprint(" Usage: /copy [number]") + return + if idx < 0 or idx >= len(assistant): + _cprint(f" Invalid response number. Use 1-{len(assistant)}.") + return + else: + idx = len(assistant) - 1 + while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")): + idx -= 1 + if idx < 0: + _cprint(" Nothing to copy in assistant responses yet.") + return + + text = _assistant_copy_text(assistant[idx].get("content")) + if not text: + _cprint(" Nothing to copy in that assistant response.") + return + + try: + self._write_osc52_clipboard(text) + _cprint(f" Copied assistant response #{idx + 1} to clipboard") + except Exception as e: + _cprint(f" Clipboard copy failed: {e}") + def _preprocess_images_with_vision(self, text: str, images: list) -> str: """Analyze attached images via the vision tool and return enriched text. @@ -4598,6 +4696,8 @@ class HermesCLI: self._show_usage() elif canonical == "insights": self._show_insights(cmd_original) + elif canonical == "copy": + self._handle_copy_command(cmd_original) elif canonical == "paste": self._handle_paste_command() elif canonical == "reload-mcp": @@ -4630,6 +4730,8 @@ class HermesCLI: self._handle_rollback_command(cmd_original) elif canonical == "stop": self._handle_stop_command() + elif canonical == "agents": + self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "btw": diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index bd07459ac..aa40ece6d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1170,7 +1170,7 @@ class BasePlatformAdapter(ABC): # session lifecycle and its cleanup races with the running task # (see PR #4926). cmd = event.get_command() - if cmd in ("approve", "deny", "status", "stop", "new", "reset"): + if cmd in ("approve", "deny", "status", "agents", "tasks", "stop", "new", "reset"): logger.debug( "[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key, diff --git a/gateway/run.py b/gateway/run.py index 339954f5b..91e4a7d56 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1997,6 +1997,10 @@ class GatewayRunner: return await self._handle_approve_command(event) return await self._handle_deny_command(event) + # /agents (/tasks alias) should be query-only and never interrupt. + if _cmd_def_inner and _cmd_def_inner.name == "agents": + return await self._handle_agents_command(event) + if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) @@ -2071,6 +2075,9 @@ class GatewayRunner: if canonical == "status": return await self._handle_status_command(event) + + if canonical == "agents": + return await self._handle_agents_command(event) if canonical == "stop": return await self._handle_stop_command(event) @@ -3412,6 +3419,96 @@ class GatewayRunner: ]) return "\n".join(lines) + + async def _handle_agents_command(self, event: MessageEvent) -> str: + """Handle /agents command - list active agents and running tasks.""" + from tools.process_registry import format_uptime_short, process_registry + + now = time.time() + current_session_key = self._session_key_for_source(event.source) + + running_agents: dict = getattr(self, "_running_agents", {}) or {} + running_started: dict = getattr(self, "_running_agents_ts", {}) or {} + + agent_rows: list[dict] = [] + for session_key, agent in running_agents.items(): + started = float(running_started.get(session_key, now)) + elapsed = max(0, int(now - started)) + is_pending = agent is _AGENT_PENDING_SENTINEL + agent_rows.append( + { + "session_key": session_key, + "elapsed": elapsed, + "state": "starting" if is_pending else "running", + "session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""), + "model": "" if is_pending else str(getattr(agent, "model", "") or ""), + } + ) + + agent_rows.sort(key=lambda row: row["elapsed"], reverse=True) + + running_processes: list[dict] = [] + try: + running_processes = [ + p for p in process_registry.list_sessions() + if p.get("status") == "running" + ] + except Exception: + running_processes = [] + + background_tasks = [ + t for t in (getattr(self, "_background_tasks", set()) or set()) + if hasattr(t, "done") and not t.done() + ] + + lines = [ + "🤖 **Active Agents & Tasks**", + "", + f"**Active agents:** {len(agent_rows)}", + ] + + if agent_rows: + for idx, row in enumerate(agent_rows[:12], 1): + current = " · this chat" if row["session_key"] == current_session_key else "" + sid = f" · `{row['session_id']}`" if row["session_id"] else "" + model = f" · `{row['model']}`" if row["model"] else "" + lines.append( + f"{idx}. `{row['session_key']}` · {row['state']} · " + f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}" + ) + if len(agent_rows) > 12: + lines.append(f"... and {len(agent_rows) - 12} more") + + lines.extend( + [ + "", + f"**Running background processes:** {len(running_processes)}", + ] + ) + if running_processes: + for proc in running_processes[:12]: + cmd = " ".join(str(proc.get("command", "")).split()) + if len(cmd) > 90: + cmd = cmd[:87] + "..." + lines.append( + f"- `{proc.get('session_id', '?')}` · " + f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`" + ) + if len(running_processes) > 12: + lines.append(f"... and {len(running_processes) - 12} more") + + lines.extend( + [ + "", + f"**Gateway async jobs:** {len(background_tasks)}", + ] + ) + + if not agent_rows and not running_processes and not background_tasks: + lines.append("") + lines.append("No active agents or running tasks.") + + return "\n".join(lines) async def _handle_stop_command(self, event: MessageEvent) -> str: """Handle /stop command - interrupt a running agent. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 70d9cb8aa..917e8b1e0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -71,6 +71,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("bg",), args_hint=""), CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session", args_hint=""), + CommandDef("agents", "Show active agents and running tasks", "Session", + aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", aliases=("q",), args_hint=""), CommandDef("status", "Show session info", "Session", @@ -134,6 +136,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", cli_only=True, aliases=("gateway",)), + CommandDef("copy", "Copy the last assistant response to clipboard", "Info", + cli_only=True, args_hint="[number]"), CommandDef("paste", "Check clipboard for an image and attach it", "Info", cli_only=True), CommandDef("update", "Update Hermes Agent to the latest version", "Info", diff --git a/run_agent.py b/run_agent.py index fc0470683..d7234f296 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4739,6 +4739,7 @@ class AIAgent: ) except Exception: pass + self._emit_status("🔄 Reconnected — resuming…") continue self._emit_status( "❌ Connection to provider failed after " diff --git a/tests/cli/test_cli_copy_command.py b/tests/cli/test_cli_copy_command.py new file mode 100644 index 000000000..6cd010df3 --- /dev/null +++ b/tests/cli/test_cli_copy_command.py @@ -0,0 +1,71 @@ +"""Tests for CLI /copy command.""" + +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +def _make_cli() -> HermesCLI: + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = "sess-copy-test" + cli_obj._pending_input = MagicMock() + cli_obj._app = None + return cli_obj + + +def test_copy_copies_latest_assistant_message(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "first"}, + {"role": "assistant", "content": "latest"}, + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + result = cli_obj.process_command("/copy") + + assert result is True + mock_copy.assert_called_once_with("latest") + + +def test_copy_with_index_uses_requested_assistant_message(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "assistant", "content": "one"}, + {"role": "assistant", "content": "two"}, + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + cli_obj.process_command("/copy 1") + + mock_copy.assert_called_once_with("one") + + +def test_copy_strips_reasoning_blocks_before_copy(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + { + "role": "assistant", + "content": "internal\nVisible answer", + } + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + cli_obj.process_command("/copy") + + mock_copy.assert_called_once_with("Visible answer") + + +def test_copy_invalid_index_does_not_copy(): + cli_obj = _make_cli() + cli_obj.conversation_history = [{"role": "assistant", "content": "only"}] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print: + cli_obj.process_command("/copy 99") + + mock_copy.assert_not_called() + assert any("Invalid response number" in str(call) for call in mock_print.call_args_list) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index e90dee69c..e36a1473f 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -160,6 +160,30 @@ class TestCommandBypassActiveSession: assert sk not in adapter._pending_messages assert any("handled:status" in r for r in adapter.sent_responses) + @pytest.mark.asyncio + async def test_agents_bypasses_guard(self): + """/agents must bypass so active-task queries don't interrupt runs.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/agents")) + + assert sk not in adapter._pending_messages + assert any("handled:agents" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_tasks_alias_bypasses_guard(self): + """/tasks alias must bypass active-session guard too.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/tasks")) + + assert sk not in adapter._pending_messages + assert any("handled:tasks" in r for r in adapter.sent_responses) + # --------------------------------------------------------------------------- # Tests: non-bypass messages still get queued diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 0dbd5980b..1cd58f3c6 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -1,6 +1,7 @@ """Tests for gateway /status behavior and token persistence.""" from datetime import datetime +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -111,6 +112,75 @@ async def test_status_command_includes_session_title_when_present(): assert "**Title:** My titled session" in result +@pytest.mark.asyncio +async def test_agents_command_reports_active_agents_and_processes(monkeypatch): + session_key = build_session_key(_make_source()) + session_entry = SessionEntry( + session_key=session_key, + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + running_agent = SimpleNamespace( + session_id="sess-running", + model="openrouter/test-model", + interrupt=MagicMock(), + get_activity_summary=lambda: {"seconds_since_activity": 0}, + ) + runner._running_agents[session_key] = running_agent + runner._running_agents_ts = {session_key: time.time() - 8} + runner._background_tasks = set() + + class _FakeRegistry: + def list_sessions(self): + return [ + { + "session_id": "proc-1", + "status": "running", + "uptime_seconds": 17, + "command": "sleep 30", + } + ] + + monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry()) + + result = await runner._handle_message(_make_event("/agents")) + + assert "**Active agents:** 1" in result + assert "**Running background processes:** 1" in result + assert "proc-1" in result + running_agent.interrupt.assert_not_called() + + +@pytest.mark.asyncio +async def test_tasks_alias_routes_to_agents_command(monkeypatch): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + runner._background_tasks = set() + + class _FakeRegistry: + def list_sessions(self): + return [] + + monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry()) + + result = await runner._handle_message(_make_event("/tasks")) + + assert "Active Agents & Tasks" in result + + @pytest.mark.asyncio async def test_handle_message_persists_agent_token_counts(monkeypatch): import gateway.run as gateway_run diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 81c262a84..3b57bf07a 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -82,6 +82,8 @@ class TestResolveCommand: def test_canonical_name_resolves(self): assert resolve_command("help").name == "help" assert resolve_command("background").name == "background" + assert resolve_command("copy").name == "copy" + assert resolve_command("agents").name == "agents" def test_alias_resolves_to_canonical(self): assert resolve_command("bg").name == "background" @@ -91,6 +93,7 @@ class TestResolveCommand: assert resolve_command("gateway").name == "platforms" assert resolve_command("set-home").name == "sethome" assert resolve_command("reload_mcp").name == "reload-mcp" + assert resolve_command("tasks").name == "agents" def test_leading_slash_stripped(self): assert resolve_command("/help").name == "help" diff --git a/tools/process_registry.py b/tools/process_registry.py index b935f49c3..2adad9e47 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -59,6 +59,17 @@ FINISHED_TTL_SECONDS = 1800 # Keep finished processes for 30 minutes MAX_PROCESSES = 64 # Max concurrent tracked processes (LRU pruning) +def format_uptime_short(seconds: int) -> str: + s = max(0, int(seconds)) + if s < 60: + return f"{s}s" + mins, secs = divmod(s, 60) + if mins < 60: + return f"{mins}m {secs}s" + hours, mins = divmod(mins, 60) + return f"{hours}h {mins}m" + + @dataclass class ProcessSession: """A tracked background process with output buffering.""" diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 96bbfc720..726ea9f9c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -297,8 +297,10 @@ export function App({ gw }: { gw: GatewayClient }) { const colsRef = useRef(cols) const turnToolsRef = useRef([]) const statusTimerRef = useRef | null>(null) + const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) colsRef.current = cols + busyRef.current = busy reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -1011,12 +1013,19 @@ export function App({ gw }: { gw: GatewayClient }) { if (p?.text) { setStatus(p.text) - if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) { - lastStatusNoteRef.current = p.text - pushActivity( - p.text, - p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' - ) + if (p.kind && p.kind !== 'status') { + if (lastStatusNoteRef.current !== p.text) { + lastStatusNoteRef.current = p.text + pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) + } + if (statusTimerRef.current) clearTimeout(statusTimerRef.current) + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus(busyRef.current ? 'running…' : 'ready') + }, 4000) } }