Skip to content
Closed
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
97 changes: 97 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,40 @@ def _reset_terminal_input_modes_on_exit() -> None:
_active_worktree: Optional[Dict[str, str]] = None


def _build_concurrent_sessions_note(concurrent: list) -> str:
"""Build a structured block for the agent's system prompt.

When another Hermes session is active in the same git repository the agent
must know about it so it avoids destructive operations (reset --hard,
rebase, checkout --) that would clobber the other session's in-flight work.

Returns an empty string when there are no concurrent sessions.
"""
if not concurrent:
return ''
import datetime as _dt
lines = [
'[Concurrent sessions]',
'Other active Hermes sessions are open in this repository:',
]
for e in concurrent:
sid = (e.get('session_id') or '?')[:24]
pid = e.get('pid', '?')
branch = (e.get('metadata') or {}).get('branch', 'unknown')
started_raw = e.get('started_at')
try:
started = _dt.datetime.fromtimestamp(float(started_raw)).strftime('%H:%M:%S')
except Exception:
started = str(started_raw or '?')
lines.append(f'- ID: {sid}, branch: {branch}, PID: {pid}, started: {started}')
lines.append(
'Uncommitted changes may exist in the shared working tree. '
'Avoid destructive git operations (reset --hard, checkout --, rebase) '
'unless you have confirmed no other session holds in-flight work.'
)
return '\n'.join(lines)


def _normalize_git_bash_path(p: Optional[str]) -> Optional[str]:
"""Translate a Git Bash-style path (``/c/Users/...``) to the native
Windows form (``C:\\Users\\...``) that Python's ``subprocess.Popen``
Expand Down Expand Up @@ -1340,6 +1374,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
except Exception as e:
logger.debug("Error copying .worktreeinclude entries: %s", e)

# Lock the worktree so other processes (and `git worktree remove`) can see
# it is actively in use. Fail-soft: a lock failure never blocks the session.
try:
subprocess.run(
["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
except Exception as e:
logger.debug("git worktree lock failed (non-fatal): %s", e)

info = {
"path": str(wt_path),
"branch": branch_name,
Expand Down Expand Up @@ -1405,6 +1450,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
if not Path(wt_path).exists():
return

# Unlock before removal so `git worktree remove` succeeds even when a
# lock was placed at creation time. Fail-soft — never block cleanup.
try:
subprocess.run(
["git", "worktree", "unlock", wt_path],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("git worktree unlock failed (non-fatal): %s", e)

has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)

if has_unpushed:
Expand Down Expand Up @@ -3612,10 +3667,16 @@ def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -
try:
from hermes_cli.active_sessions import try_acquire_active_session

# Store repo_root in the registry entry so that
# find_concurrent_repo_sessions() can match sessions by repository.
_repo_root = getattr(self, '_repo_root', None) or _git_repo_root()
_meta = {'repo_root': _repo_root} if _repo_root else None

lease, message = try_acquire_active_session(
session_id=self.session_id,
surface=surface,
config=self.config,
metadata=_meta,
)
except Exception as exc:
logger.warning("Failed to claim active session slot: %s", exc)
Expand Down Expand Up @@ -10959,6 +11020,27 @@ def run(self):
pass

self.show_banner()
# Warn when another Hermes session is already running in the same repo.
# Both sessions share the working tree and can clobber each other's
# uncommitted changes. We also inject a note into system_prompt so
# the agent itself acts defensively before the first user message.
try:
from hermes_cli.active_sessions import find_concurrent_repo_sessions
_repo_root = getattr(self, '_repo_root', None) or _git_repo_root()
if _repo_root:
_concurrent = find_concurrent_repo_sessions(_repo_root, self.session_id)
if _concurrent:
_ids = ', '.join(e.get('session_id', '?')[:20] for e in _concurrent)
self._console_print(
f'[bold yellow]⚠ Concurrent session(s) detected in this repo:[/] {_ids}\n'
f'[yellow] Both sessions share the same working tree. '
f'Use [bold]hermes -w[/bold] (--worktree) to isolate each session on its own branch.[/]'
)
_note = _build_concurrent_sessions_note(_concurrent)
if _note:
self.system_prompt = ((self.system_prompt or '') + '\n\n' + _note).strip()
except Exception:
pass
# Surface any active supply-chain security advisories right after the
# welcome banner. Quiet/single-query paths call this themselves.
self._show_security_advisories()
Expand Down Expand Up @@ -13756,6 +13838,21 @@ def _signal_handler_q(signum, frame):
if query or image:
if not cli._claim_active_session("cli", stderr=bool(quiet)):
sys.exit(1)
# Concurrent-session check for single-query / piped mode.
# Uses stderr so the stdout output stays clean for downstream consumers.
try:
from hermes_cli.active_sessions import find_concurrent_repo_sessions
_sq_repo = getattr(cli, '_repo_root', None) or _git_repo_root()
if _sq_repo:
_sq_concurrent = find_concurrent_repo_sessions(_sq_repo, cli.session_id)
if _sq_concurrent:
_sq_ids = ', '.join(e.get('session_id', '?')[:20] for e in _sq_concurrent)
print(f'⚠ Concurrent Hermes session(s) in this repo: {_sq_ids}', file=sys.stderr)
_note = _build_concurrent_sessions_note(_sq_concurrent)
if _note:
cli.system_prompt = ((cli.system_prompt or '') + '\n\n' + _note).strip()
except Exception:
pass
try:
query, single_query_images = _collect_query_images(query, image)
# Kanban workers spawn with ``hermes chat -q "work kanban task <id>"``;
Expand Down
42 changes: 41 additions & 1 deletion hermes_cli/active_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,18 @@ def active_session_limit_message(active_count: int, max_sessions: int) -> str:


def _state_dir() -> Path:
return get_hermes_home() / "runtime"
# Allow an explicit override for CI and container setups.
override = os.environ.get("HERMES_SESSIONS_STATE", "").strip()
if override:
return Path(override)
# Anchor to the *installation root* rather than the active profile home so
# that Desktop, WebUI, and CLI sessions — which may each have a different
# $HERMES_HOME when profiles are in use — all write to the same registry.
try:
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "runtime"
except Exception:
return get_hermes_home() / "runtime"


def _state_path() -> Path:
Expand Down Expand Up @@ -318,3 +329,32 @@ def active_session_registry_snapshot() -> list[dict[str, Any]]:
entries = _prune_dead(_read_entries(state_path))
_write_entries(state_path, entries)
return entries


def find_concurrent_repo_sessions(
repo_root: str,
our_session_id: str,
) -> list[dict[str, Any]]:
"""Return live sessions sharing the same git repository, excluding ours.

Used to warn the user (and the agent) that another Hermes session is
already active in the same working tree so they can avoid clobbering
each other's uncommitted changes.

Returns an empty list when no concurrency is detected or the registry
is unavailable — callers must never fail if this function raises.
"""
if not repo_root:
return []
try:
state_path = _state_path()
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path))
_write_entries(state_path, entries)
return [
entry for entry in entries
if entry.get("session_id") != our_session_id
and (entry.get("metadata") or {}).get("repo_root") == repo_root
]
except Exception:
return []
60 changes: 40 additions & 20 deletions plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,23 +440,34 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
session = self._manager.get_or_create(self._session_key)

# ----- B6: Memory file migration (one-time, for new sessions) -----
# Skip under per-session strategy: every Hermes run creates a fresh
# Honcho session by design, so uploading MEMORY.md/USER.md/SOUL.md to
# each one would flood the backend with short-lived duplicates instead
# of performing a one-time migration.
# With per-directory strategy: migrate once per new session (safe, idempotent).
# With per-session strategy: every Hermes run is a fresh session, so migrating
# on every start would flood the Honcho backend with duplicates. Instead we
# migrate exactly once (first run after install) and mark it with a marker file.
try:
if not session.messages and cfg.session_strategy != "per-session":
if session.messages:
pass # existing session — nothing to migrate
elif cfg.session_strategy != "per-session":
from hermes_constants import get_hermes_home
mem_dir = str(get_hermes_home() / "memories")
self._manager.migrate_memory_files(self._session_key, mem_dir)
logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key)
elif cfg.session_strategy == "per-session":
logger.debug(
"Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)",
self._session_key,
)
_hermes_home = get_hermes_home()
self._manager.migrate_memory_files(self._session_key, str(_hermes_home / "memories"))
logger.debug("Honcho memory migration attempted for new session: %s", self._session_key)
else:
from hermes_constants import get_hermes_home
_hermes_home = get_hermes_home()
_marker = _hermes_home / ".honcho-memory-migrated"
if _marker.exists():
logger.debug("Honcho memory migration skipped: already done (%s)", self._session_key)
else:
migrated = self._manager.migrate_memory_files(self._session_key, str(_hermes_home / "memories"))
if migrated:
try:
_marker.touch()
except Exception:
pass
logger.debug("Honcho memory migration (one-time) for %s", self._session_key)
except Exception as e:
logger.debug("Honcho memory file migration skipped: %s", e)
logger.debug("Honcho memory migration skipped: %s", e)

# ----- B7: Pre-warming at init -----
# Context prewarm warms peer.context() (base layer), consumed via
Expand Down Expand Up @@ -550,26 +561,35 @@ def _format_first_turn_context(self, ctx: dict) -> str:
"""Format the prefetch context dict into a readable system prompt block."""
parts = []

# Session summary — session-scoped context, placed first for relevance
# Session summary is session-scoped: it only reflects the current run.
# We tag it with the session key so the agent can distinguish it from
# the peer-level sections that span multiple sessions (see note below).
summary = ctx.get("summary", "")
if summary:
parts.append(f"## Session Summary\n{summary}")
session_tag = f" [session: {self._session_key}]" if self._session_key else ""
parts.append(f"## Session Summary{session_tag}\n{summary}")

# Peer-level sections (representation, card) are aggregated by Honcho
# across ALL sessions for this peer — the Honcho API does not expose a
# per-session filter for these fields. The note makes that explicit so
# the agent doesn't treat cross-session observations as current-run facts.
_peer_scope = "*(aggregated across all sessions — not limited to the current one)*"

rep = ctx.get("representation", "")
if rep:
parts.append(f"## User Representation\n{rep}")
parts.append(f"## User Representation {_peer_scope}\n{rep}")

card = ctx.get("card", "")
if card:
parts.append(f"## User Peer Card\n{card}")
parts.append(f"## User Peer Card {_peer_scope}\n{card}")

ai_rep = ctx.get("ai_representation", "")
if ai_rep:
parts.append(f"## AI Self-Representation\n{ai_rep}")
parts.append(f"## AI Self-Representation {_peer_scope}\n{ai_rep}")

ai_card = ctx.get("ai_card", "")
if ai_card:
parts.append(f"## AI Identity Card\n{ai_card}")
parts.append(f"## AI Identity Card {_peer_scope}\n{ai_card}")

if not parts:
return ""
Expand Down
9 changes: 6 additions & 3 deletions plugins/memory/honcho/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,11 @@ class HonchoClientConfig:
user_observe_others: bool = True
ai_observe_me: bool = True
ai_observe_others: bool = True
# Session resolution
session_strategy: str = "per-directory"
# Session resolution.
# "per-session" gives every Hermes run its own isolated Honcho session,
# which prevents memory cross-bleed when two sessions share a directory.
# Set "per-directory" explicitly to persist context across runs in a project.
session_strategy: str = "per-session"
session_peer_prefix: bool = False
sessions: dict[str, str] = field(default_factory=dict)
# Raw global config for anything else consumers need
Expand Down Expand Up @@ -493,7 +496,7 @@ def from_global_config(
# sessionStrategy / sessionPeerPrefix: host first, root fallback
session_strategy = (
host_block.get("sessionStrategy")
or raw.get("sessionStrategy", "per-directory")
or raw.get("sessionStrategy", "per-session")
)
host_prefix = host_block.get("sessionPeerPrefix")
session_peer_prefix = (
Expand Down
4 changes: 2 additions & 2 deletions tests/honcho_plugin/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_default_values(self):
assert config.timeout is None
assert config.enabled is False
assert config.save_messages is True
assert config.session_strategy == "per-directory"
assert config.session_strategy == "per-session"
assert config.recall_mode == "hybrid"
assert config.session_peer_prefix is False
assert config.sessions == {}
Expand Down Expand Up @@ -167,7 +167,7 @@ def test_session_strategy_default_from_global_config(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"apiKey": "***"}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.session_strategy == "per-directory"
assert config.session_strategy == "per-session"

def test_context_tokens_default_is_none(self, tmp_path):
"""Default context_tokens should be None (uncapped) unless explicitly set."""
Expand Down