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}>.*?{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)
}
}