Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5484,6 +5484,156 @@ def new_session(self, silent=False, title=None):
else:
print("(^_^)v New session started!")

def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.

Flow:
1. Validate platform name + the gateway has a home channel for it.
2. Reject if the agent is currently running (the in-flight turn
would race with the gateway's switch_session).
3. Write ``handoff_state='pending'`` on this session row.
4. Block-poll ``state.db`` for terminal state (timeout 60s).
5. On ``completed`` → print resume hint and signal CLI exit by
returning False (the caller honors that like ``/quit``).
6. On ``failed`` / timeout → print error and return True so the
user keeps their CLI session.

Returns:
False to signal CLI exit, True to keep going.
"""
from hermes_state import format_session_db_unavailable

parts = cmd_original.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
_cprint(" Usage: /handoff <platform>")
_cprint(" Hands the current session off to that platform's home channel.")
_cprint(" The CLI session ends here; resume it later with /resume.")
return True

platform_name = parts[1].strip().lower()

# Validate platform name + home channel via the live gateway config.
try:
from gateway.config import load_gateway_config, Platform
except Exception as exc: # pragma: no cover — gateway pkg always shipped
_cprint(f" Could not load gateway config: {exc}")
return True

try:
platform = Platform(platform_name)
except (ValueError, KeyError):
_cprint(f" Unknown platform '{platform_name}'.")
return True

try:
gw_config = load_gateway_config()
except Exception as exc:
_cprint(f" Could not load gateway config: {exc}")
return True

pcfg = gw_config.platforms.get(platform)
if not pcfg or not pcfg.enabled:
_cprint(f" Platform '{platform_name}' is not configured/enabled in the gateway.")
return True

home = gw_config.get_home_channel(platform)
if not home or not home.chat_id:
_cprint(f" No home channel configured for {platform_name}.")
_cprint(f" Set one with /sethome on the destination chat first.")
return True

# Refuse mid-turn: an in-flight agent run would race with the
# gateway's switch_session and the synthetic turn dispatch.
if getattr(self, "_agent_running", False):
_cprint(" Agent is busy. Wait for the current turn to finish, then retry /handoff.")
return True

# Make sure we have a SessionDB handle.
if not self._session_db:
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception:
pass
if not self._session_db:
_cprint(f" {format_session_db_unavailable()}")
return True

# Make sure the session row exists in state.db. Most CLI sessions
# are written via _flush_messages_to_session_db on the first turn
# already, but if the user tries to hand off an empty session we
# still want a row to mark.
try:
row = self._session_db.get_session(self.session_id)
if not row:
# Nothing has flushed yet. Create a stub so the gateway has
# something to switch_session onto. Inserting via title-set
# is the simplest path because set_session_title's INSERT OR
# IGNORE creates the row.
placeholder_title = f"handoff-{self.session_id[:8]}"
self._session_db.set_session_title(self.session_id, placeholder_title)
except Exception as exc:
_cprint(f" Could not ensure session row in state.db: {exc}")
return True

# Display title for messaging.
session_title = ""
try:
row = self._session_db.get_session(self.session_id)
if row:
session_title = row.get("title") or ""
except Exception:
pass
if not session_title:
session_title = self.session_id[:8]

# Mark pending — gateway watcher will pick this up.
ok = self._session_db.request_handoff(self.session_id, platform_name)
if not ok:
_cprint(" Session is already in flight for handoff. Wait for it to settle, then retry.")
return True

_cprint(f" Queued handoff of '{session_title}' → {platform_name} (home: {home.name}).")
_cprint(f" Waiting for the gateway to pick it up...")

# Poll-block on terminal state. Tick every 0.5s; bail at ~60s.
import time as _time
deadline = _time.time() + 60.0
last_state = "pending"
while _time.time() < deadline:
try:
state_row = self._session_db.get_handoff_state(self.session_id)
except Exception:
state_row = None
current = (state_row or {}).get("state") or "pending"
if current != last_state:
if current == "running":
_cprint(" Gateway picked it up; transferring...")
last_state = current
if current == "completed":
_cprint("")
_cprint(f" ↻ Handoff complete. The session is now active on {platform_name}.")
_cprint(f" Resume it on this CLI later with: /resume {session_title}")
_cprint("")
# End the CLI cleanly — same exit semantics as /quit.
self._should_exit = True
return False
if current == "failed":
err = (state_row or {}).get("error") or "unknown error"
_cprint(f" Handoff failed: {err}")
_cprint(" Your CLI session is intact. Try /handoff again, or /resume on the platform manually.")
return True
_time.sleep(0.5)

# Timed out. Clear the pending flag so the user can retry.
try:
self._session_db.fail_handoff(self.session_id, "timed out waiting for gateway")
except Exception:
pass
_cprint(" Timed out waiting for the gateway. Is `hermes gateway` running?")
_cprint(" Your CLI session is intact.")
return True

def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
parts = cmd_original.split(None, 1)
Expand Down Expand Up @@ -6910,6 +7060,9 @@ def process_command(self, command: str) -> bool:
else:
from hermes_state import format_session_db_unavailable
_cprint(f" {format_session_db_unavailable()}")
elif canonical == "handoff":
if not self._handle_handoff_command(cmd_original):
return False
elif canonical == "new":
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None
Expand Down
27 changes: 27 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,33 @@ async def send(
# property) so the stream consumer knows not to short-circuit.
REQUIRES_EDIT_FINALIZE: bool = False

async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
) -> Optional[str]:
"""Create a fresh thread under ``parent_chat_id`` for a session handoff.

Used by the gateway's handoff watcher when transferring a CLI
session to a thread-capable platform — the new thread isolates the
handed-off conversation from any pre-existing chat in the home
channel and gives users a clean per-handoff scrollback.

Returns the new thread/topic id (as a string) on success, or
``None`` if the platform doesn't support threading or the
attempt failed (permissions, topics-mode off, etc.). When ``None``
is returned the watcher falls back to using ``parent_chat_id``
directly.

Default implementation returns ``None`` — adapters that support
threads override this. See:
- Telegram: forum topics in groups, DM topics with bot API 9.4+
- Discord: text-channel threads (1440-min auto-archive)
- Slack: seed-message thread anchoring
"""
return None


async def edit_message(
self,
chat_id: str,
Expand Down
78 changes: 78 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -3689,6 +3689,84 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
)
return None

async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
) -> Optional[str]:
"""Create a Discord thread under a text channel for a handoff.

Falls back to a seed-message + ``message.create_thread`` path if
``parent.create_thread`` is rejected (some channel types or
permission setups). Returns the new thread id as a string, or
``None`` on failure or when the parent isn't a text channel
(DMs, voice channels, threads themselves can't host threads).
"""
if not self._client or not DISCORD_AVAILABLE:
return None

try:
parent_id = int(parent_chat_id)
except (TypeError, ValueError):
return None

try:
parent = self._client.get_channel(parent_id)
if parent is None:
parent = await self._client.fetch_channel(parent_id)
except Exception as exc:
logger.warning(
"[%s] Handoff thread: cannot resolve parent %s: %s",
self.name, parent_chat_id, exc,
)
return None

# DMs, voice channels, and existing threads can't host child threads.
if isinstance(parent, getattr(discord, "DMChannel", tuple())):
logger.info(
"[%s] Handoff thread: parent %s is a DM; threads not supported here",
self.name, parent_chat_id,
)
return None

thread_name = (name or "handoff").strip()[:80] or "handoff"
reason = "Hermes session handoff"

# First try: create a thread directly on the channel.
try:
create = getattr(parent, "create_thread", None)
if create is not None:
thread = await create(
name=thread_name,
auto_archive_duration=1440,
reason=reason,
)
return str(thread.id)
except Exception as direct_error:
logger.debug(
"[%s] Handoff thread: direct create failed (%s); trying seed-message fallback",
self.name, direct_error,
)

# Fallback: post a seed message and create the thread from it.
try:
send = getattr(parent, "send", None)
if send is None:
return None
seed_msg = await send(f"\U0001f9f5 Hermes handoff: **{thread_name}**")
thread = await seed_msg.create_thread(
name=thread_name,
auto_archive_duration=1440,
reason=reason,
)
return str(thread.id)
except Exception as fallback_error:
logger.warning(
"[%s] Handoff thread: both create paths failed for parent %s: %s",
self.name, parent_chat_id, fallback_error,
)
return None

async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
Expand Down
35 changes: 35 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,41 @@ async def handle_hermes_command(ack, command):
if lock_acquired and not self._running:
self._release_platform_lock()

async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
) -> Optional[str]:
"""Create a Slack thread anchor for a session handoff.

Slack threads are anchored to a parent message (``thread_ts``), not
a channel-level construct. So we post a seed message into the home
channel and return its ``ts`` — the watcher uses that as the
``thread_id`` for subsequent sends.

Returns the seed message ts as a string, or ``None`` on failure.
"""
if not self._app:
return None
try:
client = self._get_client(parent_chat_id)
if client is None:
return None
seed_text = f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*"
result = await client.chat_postMessage(
channel=parent_chat_id,
text=seed_text,
)
ts = result.get("ts") if isinstance(result, dict) else getattr(result, "get", lambda _k, _d=None: None)("ts")
if ts:
return str(ts)
except Exception as exc:
logger.warning(
"[%s] Handoff thread: seed-post failed for channel %s: %s",
self.name, parent_chat_id, exc,
)
return None

async def disconnect(self) -> None:
"""Disconnect from Slack."""
if self._handler:
Expand Down
18 changes: 18 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,24 @@ async def _create_dm_topic(
)
return None

async def create_handoff_thread(
self,
parent_chat_id: str,
name: str,
) -> Optional[str]:
"""Create a forum topic for a session handoff.

Works for DM topics (Bot API 9.4+, requires user to enable Topics
in their chat with the bot) and forum supergroups. Returns the
``message_thread_id`` as a string, or ``None`` on failure.
"""
try:
chat_id_int = int(parent_chat_id)
except (TypeError, ValueError):
return None
thread_id = await self._create_dm_topic(chat_id_int, name=name)
return str(thread_id) if thread_id else None

async def rename_dm_topic(
self,
chat_id: int,
Expand Down
Loading
Loading