Desktop multi-profile bugs, crashes, and worktree isolation

NousResearch/hermes-agent · cluster 652 · agent

Data as of

4 open items. Fix merged in #654. Check it covers your case.

The short answer

2open issues
2open PRs
13items grouped
Known?
Yes. 13 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #654. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #654, #44446. That is separate from the fix event.
Still open
2 issues and 2 PRs.

A mix of bug reports and fixes around Windows execvpe crashes, non-default profile failures in Hermes Desktop, concurrent session cross-contamination, and a feature adding git worktree isolation for parallel CLI sessions. The members span several related but distinct problem areas under multi-profile and multi-session handling.

Open issues2

  • Issue#43953[Bug]: Hermes Desktop crashes when running multiple profile sessions at the same time
  • Issue#46303[Bug]: Concurrent sessions cross-contaminate (shared memory injection + shared git worktree) with no isolation or awareness

Open PRs2

  • PR#44089fix(desktop): keep silent profile turns alive past idle reap
  • PR#80034fix(status): show live sessions when no concurrency cap is set

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #654 feat: git worktree isolation for parallel CLI sessions (--worktree / -w)

    merged to main

    files: AGENTS.md, cli-config.yaml.example, cli.py, hermes_cli/main.py, tests/test_worktree.py, website/docs/reference/cli-commands.md, website/docs/user-guide/cli.md, website/docs/user-guide/configuration.md

    Show the diff
    • AGENTS.md
      @@ -226,6 +226,7 @@ The unified `hermes` command provides all functionality:
       |---------|-------------|
       | `hermes` | Interactive chat (default) |
       | `hermes chat -q "..."` | Single query mode |
      +| `hermes -w` / `hermes --worktree` | Start in isolated git worktree (for parallel agents) |
       | `hermes setup` | Configure API keys and settings |
       | `hermes config` | View current configuration |
       | `hermes config edit` | Open config in editor |
    • cli-config.yaml.example
      @@ -50,6 +50,16 @@ model:
       #   # Data policy: "allow" (default) or "deny" to exclude providers that may store data
       #   # data_collection: "deny"
       
      +# =============================================================================
      +# Git Worktree Isolation
      +# =============================================================================
      +# When enabled, each CLI session creates an isolated git worktree so multiple
      +# agents can work on the same repo concurrently without file collisions.
      +# Equivalent to always passing --worktree / -w on the command line.
      +#
      +# worktree: true    # Always create a worktree when in a git repo
      +# worktree: false   # Default — only create when -w flag is passed
      +
       # =============================================================================
       # Terminal Tool Configuration
       # =============================================================================
    • cli.py
      @@ -395,6 +395,227 @@ def _run_cleanup():
           except Exception:
               pass
       
      +
      +# =============================================================================
      +# Git Worktree Isolation (#652)
      +# =============================================================================
      +
      +# Tracks the active worktree for cleanup on exit
      +_active_worktree: Optional[Dict[str, str]] = None
      +
      +
      +def _git_repo_root() -> Optional[str]:
      +    """Return the git repo root for CWD, or None if not in a repo."""
      +    import subprocess
      +    try:
      +        result = subprocess.run(
      +            ["git", "rev-parse", "--show-toplevel"],
      +            capture_output=True, text=True, timeout=5,
      +        )
      +        if result.returncode == 0:
      +            return result.stdout.strip()
      +    except Exception:
      +        pass
      +    return None
      +
      +
      +def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
      +    """Create an isolated git worktree for this CLI session.
      +
      +    Returns a dict with worktree metadata on success, None on failure.
      +    The dict contains: path, branch, repo_root.
      +    """
      +    import subprocess
      +
      +    repo_root = repo_root or _git_repo_root()
      +    if not repo_root:
      +        print("\033[33m⚠ --worktree: not inside a git repository, skipping.\033[0m")
      +        return None
      +
      +    short_id = uuid.uuid4().hex[:8]
      +    wt_name = f"hermes-{short_id}"
      +    branch_name = f"hermes/{wt_name}"
      +
      +    worktrees_dir = Path(repo_root) / ".worktrees"
      +    worktrees_dir.mkdir(parents=True, exist_ok=True)
      +
      +    wt_path = worktrees_dir / wt_name
      +
      +    # Ensure .worktrees/ is in .gitignore
      +    gitignore = Path(repo_root) / ".gitignore"
      +    _ignore_entry = ".worktrees/"
      +    try:
      +        existing = gitignore.read_text() if gitignore.exists() else ""
      +        if _ignore_entry not in existing.splitlines():
      +            with open(gitignore, "a") as f:
      +                if existing and not existing.endswith("\n"):
      +                    f.write("\n")
      +                f.write(f"{_ignore_entry}\n")
      +    except Exception as e:
      +        logger.debug("Could not update .gitignore: %s", e)
      +
      +    # Create the worktree
      +    try:
      +        result = subprocess.run(
      +            ["git", "worktree", "add", str(wt_path), "-b", branch_name, "HEAD"],
      +            capture_output=True, text=True, timeout=30, cwd=repo_root,
      +        )
      +        if result.returncode != 0:
      +            print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m")
      +            return None
      +    except Exception as e:
      +        print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
      +        return None
      +
      +    # Copy files listed in .worktreeinclude (gitignored files the agent needs)
      +    include_file = Path(repo_root) / ".worktreeinclude"
      +    if include_file.exists():
      +        try:
      +            for line in include_file.read_text().splitlines():
      +                entry = line.strip()
      +                if not entry or entry.startswith("#"):
      +                    continue
      +                src = Path(repo_root) / entry
      +                dst = wt_path / entry
      +                if src.is_file():
      +                    dst.parent.mkdir(parents=True, exist_ok=True)
      +                    shutil.copy2(str(src), str(dst))
      +                elif src.is_dir():
      +                    # Symlink directories (faster, saves disk)
      +                    if not dst.exists():
      +                        dst.parent.mkdir(parents=True, exist_ok=True)
      +                        os.symlink(str(src.resolve()), str(dst))
      +        except Exception as e:
      +            logger.debug("Error copying .worktreeinclude entries: %s", e)
      +
      +    info = {
      +        "path": str(wt_path),
      +        "branch": branch_name,
      +        "repo_root": repo_root,
      +    }
      +
      +    print(f"\033[32m✓ Worktree created:\033[0m {wt_path}")
      +    print(f"  Branch: {branch_name}")
      +
      +    return info
      +
      +
      +def _cleanup_worktree(info: Dict[str, str] = None) -> None:
      +    """Remove a worktree and its branch on exit.
      +
      +    If the worktree has uncommitted changes, warn and keep it.
      +    """
      +    global _active_worktree
      +    info = info or _active_worktree
      +    if not info:
      +        return
      +
      +    import subprocess
      +
      +    wt_path = info["path"]
      +    branch = info["branch"]
      +    repo_root = info["repo_root"]
      +
      +    if not Path(wt_path).exists():
      +        return
      +
      +    # Check for uncommitted changes
      +    try:
      +        status = subprocess.run(
      +            ["git", "status", "--porcelain"],
      +            capture_output=True, text=True, timeout=10, cwd=wt_path,
      +        )
      +        has_changes = bool(status.stdout.strip())
      +    except Exception:
      +        has_changes = True  # Assume dirty on error — don't delete
      +
      +    if has_changes:
      +        print(f"\n\033[33m⚠ Worktree has uncommitted changes, keeping: {wt_path}\033[0m")
      +        print(f"  To clean up manually: git worktree remove {wt_path}")
      +        _active_worktree = None
      +        return
      +
      +    # Remove worktree
      +    try:
      +        subprocess.run(
      +            ["git", "worktree", "remove", wt_path, "--force"],
      +            capture_output=True, text=True, timeout=15, cwd=repo_root,
      +        )
      +    except Exception as e:
      +        logger.debug("Failed to remove worktree: %s", e)
      +
      +    # Delete the branch (only if it was never pushed / has no upstream)
      +    try:
      +        subprocess.run(
      +            ["git", "branch", "-D", branch],
      +            capture_output=True, text=True, timeout=10, cwd=repo_root,
      +        )
      +    except Exception as e:
      +        logger.debug("Failed to delete branch %s: %s", branch, e)
      +
      +    _active_worktree = None
      +    print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
      +
      +
      +def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
      +    """Remove worktrees older than max_age_hours that have no uncommitted changes.
      +
      +    Runs silently on startup to clean up after crashed/killed sessions.
      +    """
      +    import subprocess
      +    import time
      +
      +    worktrees_dir = Path(repo_root) / ".worktrees"
      +    if not worktrees_dir.exists():
      +        return
      +
      +    now = time.time()
      +    cutoff = now - (max_age_hours * 3600)
      +
      +    for entry in worktrees_dir.iterdir():
      +        if not entry.is_dir() or not entry.name.startswith("hermes-"):
      +            continue
      +
      +        # Check age
      +        try:
      +            mtime = entry.stat().st_mtime
      +            if mtime > cutoff:
      +                continue  # Too recent — skip
      +        except Exception:
      +            continue
      +
      +        # Check for uncommitted changes
      +        try:
      +            status = subprocess.run(
      +                ["git", "status", "--porcelain"],
      +                capture_output=True, text=True, timeout=5, cwd=str(entry),
      +            )
      +            if status.stdout.strip():
      +                continue  # Has changes — skip
      +        except Exception:
      +            continue  # Can't check — skip
      +
      +        # Safe to remove
      +        try:
      +            branch_result = subprocess.run(
      +                ["git", "branch", "--show-current"],
      +                capture_output=True, text=True, timeout=5, cwd=str(entry),
      +            )
      +            branch = branch_result.stdout.strip()
      +
      +            subprocess.run(
      +                ["git", "worktree", "remove", str(entry), "--force"],
      +                capture_output=True, text=True, timeout=15, cwd=repo_root,
      +            )
      +            if branch:
      +                subprocess.run(
      +                    ["git", "branch", "-D", branch],
      +                    capture_output=True, text=True, timeout=10, cwd=repo_root,
      +                )
      +            logger.debug("Pruned stale worktree: %s", entry.name)
      +        except Exception as e:
      +            logger.debug("Failed to prune worktree %s: %s", entry.name, e)
      +
       # ============================================================================
       # ASCII Art & Branding
       # ============================================================================
      @@ -3253,6 +3474,8 @@ def main(
           list_toolsets: bool = False,
           gateway: bool = False,
           resume: str = None,
      +    worktree: bool = False,
      +    w: bool = False,
       ):
           """
           Hermes Agent CLI - Interactive AI Assistant
      @@ -3271,6 +3494,8 @@ def main(
               list_tools: List available tools and exit
               list_toolsets: List available toolsets and exit
               resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3)
      +        worktree: Run in an isolated git worktree (for parallel agents). Alias: -w
      +        w: Shorthand for --worktree
           
           Examples:
               python cli.py                            # Start interactive mode
      @@ -3278,7 +3503,11 @@ def main(
               python cli.py -q "What is Python?"       # Single query mode
               python cli.py --list-tools               # List tools and exit
               python cli.py --resume 20260225_143052_a1b2c3  # Resume session
      +        python cli.py -w                         # Start in isolated git worktree
      +        python cli.py -w -q "Fix issue #123"     # Single query in worktree
           """
      +    global _active_worktree
      +
           # Signal to terminal_tool that we're in interactive mode
           # This enables interactive sudo password prompts with timeout
           os.environ["HERMES_INTERACTIVE"] = "1"
      @@ -3290,6 +3519,26 @@ def main(
               print("Starting Hermes Gateway (messaging platforms)...")
               asyncio.run(start_gateway())
               return
      +
      +    # Skip worktree for list commands (they exit immediately)
      +    if not list_tools and not list_toolsets:
      +        # ── Git worktree isolation (#652) ──
      +        # Create an isolated worktree so this agent instance doesn't collide
      +        # with other agents working on the same repo.
      +        use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
      +        wt_info = None
      +        if use_worktree:
      +            # Prune stale worktrees from crashed/killed sessions
      +            _repo = _git_repo_root()
      +            if _repo:
      +                _prune_stale_worktrees(_repo)
      +            wt_info = _setup_worktree()
      +            if wt_info:
      +                _active_worktree = wt_info
      +                os.environ["TERMINAL_CWD"] = wt_info["path"]
      +                atexit.register(_cleanup_worktree, wt_info)
      +    else:
      +        wt_info = None
           
           # Handle query shorthand
           query = query or q
      @@ -3328,6 +3577,17 @@ def main(
               compact=compact,
               resume=resume,
           )
      +
      +    # Inject worktree context into agent's system prompt
      +    if wt_info:
      +        wt_note = (
      +            f"\n\n[System note: You are working in an isolated git worktree at "
      +            f"{wt_info['path']}. Your branch is `{wt_info['branch']}`. "
      +            f"Changes here do not affect the main working tree or other agents. "
      +            f"Remember to commit and push your changes, and create a PR if appropriate. "
      +            f"The original repo is at {wt_info['repo_root']}.]"
      +        )
      +        cli.system_prompt = (cli.system_prompt or "") + wt_note
           
           # Handle list commands (don't init agent for these)
           if list_tools:
    • hermes_cli/main.py
      @@ -167,6 +167,7 @@ def cmd_chat(args):
               "verbose": args.verbose,
               "query": args.query,
               "resume": getattr(args, "resume", None),
      +        "worktree": getattr(args, "worktree", False),
           }
           # Filter out None values
           kwargs = {k: v for k, v in kwargs.items() if v is not None}
      @@ -1217,6 +1218,7 @@ def main():
           hermes config edit            Edit config in $EDITOR
           hermes config set model gpt-4 Set a config value
           hermes gateway                Run messaging gateway
      +    hermes -w                     Start in isolated git worktree
           hermes gateway install        Install as system service
           hermes sessions list          List past sessions
           hermes update                 Update to latest version
      @@ -1244,6 +1246,12 @@ def main():
               default=False,
               help="Resume the most recent CLI session"
           )
      +    parser.add_argument(
      +        "--worktree", "-w",
      +        action="store_true",
      +        default=False,
      +        help="Run in an isolated git worktree (for parallel agents)"
      +    )
           
           subparsers = parser.add_subparsers(dest="command", help="Command to run")
           
      @@ -1290,6 +1298,12 @@ def main():
               default=False,
               help="Resume the most recent CLI session"
           )
      +    chat_parser.add_argument(
      +        "--worktree", "-w",
      +        action="store_true",
      +        default=False,
      +        help="Run in an isolated git worktree (for parallel agents on the same repo)"
      +    )
           chat_parser.set_defaults(func=cmd_chat)
       
           # =========================================================================
      @@ -1850,6 +1864,8 @@ def cmd_insights(args):
               args.provider = None
               args.toolsets = None
               args.verbose = False
      +        if not hasattr(args, "worktree"):
      +            args.worktree = False
               cmd_chat(args)
               return
           
      @@ -1862,6 +1878,8 @@ def cmd_insights(args):
               args.verbose = False
               args.resume = None
               args.continue_last = False
      +        if not hasattr(args, "worktree"):
      +            args.worktree = False
               cmd_chat(args)
               return
           
    • tests/test_worktree.py
      @@ -0,0 +1,635 @@
      +"""Tests for git worktree isolation (CLI --worktree / -w flag).
      +
      +Verifies worktree creation, cleanup, .worktreeinclude handling,
      +.gitignore management, and integration with the CLI.  (#652)
      +"""
      +
      +import os
      +import shutil
      +import subprocess
      +import pytest
      +from pathlib import Path
      +from unittest.mock import patch, MagicMock
      +
      +
      [redacted:email]
      +def git_repo(tmp_path):
      +    """Create a temporary git repo for testing."""
      +    repo = tmp_path / "test-repo"
      +    repo.mkdir()
      +    subprocess.run(["git", "init"], cwd=repo, capture_output=True)
      +    subprocess.run(
      +        ["git", "config", "user.email", "[redacted:email]"],
      +        cwd=repo, capture_output=True,
      +    )
      +    subprocess.run(
      +        ["git", "config", "user.name", "Test"],
      +        cwd=repo, capture_output=True,
      +    )
      +    # Create initial commit (worktrees need at least one commit)
      +    (repo / "README.md").write_text("# Test Repo\n")
      +    subprocess.run(["git", "add", "."], cwd=repo, capture_output=True)
      +    subprocess.run(
      +        ["git", "commit", "-m", "Initial commit"],
      +        cwd=repo, capture_output=True,
      +    )
      +    return repo
      +
      +
      +# ---------------------------------------------------------------------------
      +# Lightweight reimplementations for testing (avoid importing cli.py)
      +# ---------------------------------------------------------------------------
      +
      +def _git_repo_root(cwd=None):
      +    """Test version of _git_repo_root."""
      +    try:
      +        result = subprocess.run(
      +            ["git", "rev-parse", "--show-toplevel"],
      +            capture_output=True, text=True, timeout=5,
      +            cwd=cwd,
      +        )
      +        if result.returncode == 0:
      +            return result.stdout.strip()
      +    except Exception:
      +        pass
      +    return None
      +
      +
      +def _setup_worktree(repo_root):
      +    """Test version of _setup_worktree — creates a worktree."""
      +    import uuid
      +    short_id = uuid.uuid4().hex[:8]
      +    wt_name = f"hermes-{short_id}"
      +    branch_name = f"hermes/{wt_name}"
      +
      +    worktrees_dir = Path(repo_root) / ".worktrees"
      +    worktrees_dir.mkdir(parents=True, exist_ok=True)
      +    wt_path = worktrees_dir / wt_name
      +
      +    result = subprocess.run(
      +        ["git", "worktree", "add", str(wt_path), "-b", branch_name, "HEAD"],
      +        capture_output=True, text=True, timeout=30, cwd=repo_root,
      +    )
      +    if result.returncode != 0:
      +        return None
      +
      +    return {
      +        "path": str(wt_path),
      +        "branch": branch_name,
      +        "repo_root": repo_root,
      +    }
      +
      +
      +def _cleanup_worktree(info):
      +    """Test version of _cleanup_worktree."""
      +    wt_path = info["path"]
      +    branch = info["branch"]
      +    repo_root = info["repo_root"]
      +
      +    if not Path(wt_path).exists():
      +        return
      +
      +    # Check for uncommitted changes
      +    status = subprocess.run(
      +        ["git", "status", "--porcelain"],
      +        capture_output=True, text=True, timeout=10, cwd=wt_path,
      +    )
      +    has_changes = bool(status.stdout.strip())
      +
      +    if has_changes:
      +        return False  # Did not clean up
      +
      +    subprocess.run(
      +        ["git", "worktree", "remove", wt_path, "--force"],
      +        capture_output=True, text=True, timeout=15, cwd=repo_root,
      +    )
      +    subprocess.run(
      +        ["git", "branch", "-D", branch],
      +        capture_output=True, text=True, timeout=10, cwd=repo_root,
      +    )
      +    return True  # Cleaned up
      +
      +
      +# ---------------------------------------------------------------------------
      +# Tests
      +# ---------------------------------------------------------------------------
      +
      +class TestGitRepoDetection:
      +    """Test git repo root detection."""
      +
      +    def test_detects_git_repo(self, git_repo):
      +        root = _git_repo_root(cwd=str(git_repo))
      +        assert root is not None
      +        assert Path(root).resolve() == git_repo.resolve()
      +
      +    def test_detects_subdirectory(self, git_repo):
      +        subdir = git_repo / "src" / "lib"
      +        subdir.mkdir(parents=True)
      +        root = _git_repo_root(cwd=str(subdir))
      +        assert root is not None
      +        assert Path(root).resolve() == git_repo.resolve()
      +
      +    def test_returns_none_outside_repo(self, tmp_path):
      +        # tmp_path itself is not a git repo
      +        bare_dir = tmp_path / "not-a-repo"
      +        bare_dir.mkdir()
      +        root = _git_repo_root(cwd=str(bare_dir))
      +        assert root is None
      +
      +
      +class TestWorktreeCreation:
      +    """Test worktree setup."""
      +
      +    def test_creates_worktree(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert Path(info["path"]).exists()
      +        assert info["branch"].startswith("hermes/hermes-")
      +        assert info["repo_root"] == str(git_repo)
      +
      +        # Verify it's a valid git worktree
      +        result = subprocess.run(
      +            ["git", "rev-parse", "--is-inside-work-tree"],
      +            capture_output=True, text=True, cwd=info["path"],
      +        )
      +        assert result.stdout.strip() == "true"
      +
      +    def test_worktree_has_own_branch(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Check branch name in worktree
      +        result = subprocess.run(
      +            ["git", "branch", "--show-current"],
      +            capture_output=True, text=True, cwd=info["path"],
      +        )
      +        assert result.stdout.strip() == info["branch"]
      +
      +    def test_worktree_is_independent(self, git_repo):
      +        """Two worktrees from the same repo are independent."""
      +        info1 = _setup_worktree(str(git_repo))
      +        info2 = _setup_worktree(str(git_repo))
      +        assert info1 is not None
      +        assert info2 is not None
      +        assert info1["path"] != info2["path"]
      +        assert info1["branch"] != info2["branch"]
      +
      +        # Create a file in worktree 1
      +        (Path(info1["path"]) / "only-in-wt1.txt").write_text("hello")
      +
      +        # It should NOT appear in worktree 2
      +        assert not (Path(info2["path"]) / "only-in-wt1.txt").exists()
      +
      +    def test_worktrees_dir_created(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert (git_repo / ".worktrees").is_dir()
      +
      +    def test_worktree_has_repo_files(self, git_repo):
      +        """Worktree should contain the repo's tracked files."""
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert (Path(info["path"]) / "README.md").exists()
      +
      +
      +class TestWorktreeCleanup:
      +    """Test worktree cleanup on exit."""
      +
      +    def test_clean_worktree_removed(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert Path(info["path"]).exists()
      +
      +        result = _cleanup_worktree(info)
      +        assert result is True
      +        assert not Path(info["path"]).exists()
      +
      +    def test_dirty_worktree_kept(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Make uncommitted changes
      +        (Path(info["path"]) / "new-file.txt").write_text("uncommitted")
      +        subprocess.run(
      +            ["git", "add", "new-file.txt"],
      +            cwd=info["path"], capture_output=True,
      +        )
      +
      +        result = _cleanup_worktree(info)
      +        assert result is False
      +        assert Path(info["path"]).exists()  # Still there
      +
      +    def test_branch_deleted_on_cleanup(self, git_repo):
      +        info = _setup_worktree(str(git_repo))
      +        branch = info["branch"]
      +
      +        _cleanup_worktree(info)
      +
      +        # Branch should be gone
      +        result = subprocess.run(
      +            ["git", "branch", "--list", branch],
      +            capture_output=True, text=True, cwd=str(git_repo),
      +        )
      +        assert branch not in result.stdout
      +
      +    def test_cleanup_nonexistent_worktree(self, git_repo):
      +        """Cleanup should handle already-removed worktrees gracefully."""
      +        info = {
      +            "path": str(git_repo / ".worktrees" / "nonexistent"),
      +            "branch": "hermes/nonexistent",
      +            "repo_root": str(git_repo),
      +        }
      +        # Should not raise
      +        _cleanup_worktree(info)
      +
      +
      +class TestWorktreeInclude:
      +    """Test .worktreeinclude file handling."""
      +
      +    def test_copies_included_files(self, git_repo):
      +        """Files listed in .worktreeinclude should be copied to the worktree."""
      +        # Create a .env file (gitignored)
      +        (git_repo / ".env").write_text("SECRET=[redacted:secret-assignment]")
      +        (git_repo / ".gitignore").write_text(".env\n.worktrees/\n")
      +        subprocess.run(
      +            ["git", "add", ".gitignore"],
      +            cwd=str(git_repo), capture_output=True,
      +        )
      +        subprocess.run(
      +            ["git", "commit", "-m", "Add gitignore"],
      +            cwd=str(git_repo), capture_output=True,
      +        )
      +
      +        # Create .worktreeinclude
      +        (git_repo / ".worktreeinclude").write_text(".env\n")
      +
      +        # Import and use the real _setup_worktree logic for include handling
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Manually copy .worktreeinclude entries (mirrors cli.py logic)
      +        import shutil
      +        include_file = git_repo / ".worktreeinclude"
      +        wt_path = Path(info["path"])
      +        for line in include_file.read_text().splitlines():
      +            entry = line.strip()
      +            if not entry or entry.startswith("#"):
      +                continue
      +            src = git_repo / entry
      +            dst = wt_path / entry
      +            if src.is_file():
      +                dst.parent.mkdir(parents=True, exist_ok=True)
      +                shutil.copy2(str(src), str(dst))
      +
      +        # Verify .env was copied
      +        assert (wt_path / ".env").exists()
      +        assert (wt_path / ".env").read_text() == "SECRET=[redacted:secret-assignment]"
      +
      +    def test_ignores_comments_and_blanks(self, git_repo):
      +        """Comments and blank lines in .worktreeinclude should be skipped."""
      +        (git_repo / ".worktreeinclude").write_text(
      +            "# This is a comment\n"
      +            "\n"
      +            "  # Another comment\n"
      +        )
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        # Should not crash — just skip all lines
      +
      +
      +class TestGitignoreManagement:
      +    """Test that .worktrees/ is added to .gitignore."""
      +
      +    def test_adds_to_gitignore(self, git_repo):
      +        """Creating a worktree should add .worktrees/ to .gitignore."""
      +        # Remove any existing .gitignore
      +        gitignore = git_repo / ".gitignore"
      +        if gitignore.exists():
      +            gitignore.unlink()
      +
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Now manually add .worktrees/ to .gitignore (mirrors cli.py logic)
      +        _ignore_entry = ".worktrees/"
      +        existing = gitignore.read_text() if gitignore.exists() else ""
      +        if _ignore_entry not in existing.splitlines():
      +            with open(gitignore, "a") as f:
      +                if existing and not existing.endswith("\n"):
      +                    f.write("\n")
      +                f.write(f"{_ignore_entry}\n")
      +
      +        content = gitignore.read_text()
      +        assert ".worktrees/" in content
      +
      +    def test_does_not_duplicate_gitignore_entry(self, git_repo):
      +        """If .worktrees/ is already in .gitignore, don't add again."""
      +        gitignore = git_repo / ".gitignore"
      +        gitignore.write_text(".worktrees/\n")
      +
      +        # The check should see it's already there
      +        existing = gitignore.read_text()
      +        assert ".worktrees/" in existing.splitlines()
      +
      +
      +class TestMultipleWorktrees:
      +    """Test running multiple worktrees concurrently (the core use case)."""
      +
      +    def test_ten_concurrent_worktrees(self, git_repo):
      +        """Create 10 worktrees — simulating 10 parallel agents."""
      +        worktrees = []
      +        for _ in range(10):
      +            info = _setup_worktree(str(git_repo))
      +            assert info is not None
      +            worktrees.append(info)
      +
      +        # All should exist and be independent
      +        paths = [info["path"] for info in worktrees]
      +        assert len(set(paths)) == 10  # All unique
      +
      +        # Each should have the repo files
      +        for info in worktrees:
      +            assert (Path(info["path"]) / "README.md").exists()
      +
      +        # Edit a file in one worktree
      +        (Path(worktrees[0]["path"]) / "README.md").write_text("Modified in wt0")
      +
      +        # Others should be unaffected
      +        for info in worktrees[1:]:
      +            assert (Path(info["path"]) / "README.md").read_text() == "# Test Repo\n"
      +
      +        # List worktrees via git
      +        result = subprocess.run(
      +            ["git", "worktree", "list"],
      +            capture_output=True, text=True, cwd=str(git_repo),
      +        )
      +        # Should have 11 entries: main + 10 worktrees
      +        lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
      +        assert len(lines) == 11
      +
      +        # Cleanup all
      +        for info in worktrees:
      +            # Discard changes first so cleanup works
      +            subprocess.run(
      +                ["git", "checkout", "--", "."],
      +                cwd=info["path"], capture_output=True,
      +            )
      +            _cleanup_worktree(info)
      +
      +        # All should be removed
      +        for info in worktrees:
      +            assert not Path(info["path"]).exists()
      +
      +
      +class TestWorktreeDirectorySymlink:
      +    """Test .worktreeinclude with directories (symlinked)."""
      +
      +    def test_symlinks_directory(self, git_repo):
      +        """Directories in .worktreeinclude should be symlinked."""
      +        # Create a .venv directory
      +        venv_dir = git_repo / ".venv" / "lib"
      +        venv_dir.mkdir(parents=True)
      +        (venv_dir / "marker.txt").write_text("venv marker")
      +        (git_repo / ".gitignore").write_text(".venv/\n.worktrees/\n")
      +        subprocess.run(
      +            ["git", "add", ".gitignore"], cwd=str(git_repo), capture_output=True
      +        )
      +        subprocess.run(
      +            ["git", "commit", "-m", "gitignore"], cwd=str(git_repo), capture_output=True
      +        )
      +
      +        (git_repo / ".worktreeinclude").write_text(".venv/\n")
      +
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        wt_path = Path(info["path"])
      +        src = git_repo / ".venv"
      +        dst = wt_path / ".venv"
      +
      +        # Manually symlink (mirrors cli.py logic)
      +        if not dst.exists():
      +            dst.parent.mkdir(parents=True, exist_ok=True)
      +            os.symlink(str(src.resolve()), str(dst))
      +
      +        assert dst.is_symlink()
      +        assert (dst / "lib" / "marker.txt").read_text() == "venv marker"
      +
      +
      +class TestStaleWorktreePruning:
      +    """Test _prune_stale_worktrees garbage collection."""
      +
      +    def test_prunes_old_clean_worktree(self, git_repo):
      +        """Old clean worktrees should be removed on prune."""
      +        import time
      +
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert Path(info["path"]).exists()
      +
      +        # Make the worktree look old (set mtime to 25h ago)
      +        old_time = time.time() - (25 * 3600)
      +        os.utime(info["path"], (old_time, old_time))
      +
      +        # Reimplementation of prune logic (matches cli.py)
      +        worktrees_dir = git_repo / ".worktrees"
      +        cutoff = time.time() - (24 * 3600)
      +
      +        for entry in worktrees_dir.iterdir():
      +            if not entry.is_dir() or not entry.name.startswith("hermes-"):
      +                continue
      +            try:
      +                mtime = entry.stat().st_mtime
      +                if mtime > cutoff:
      +                    continue
      +            except Exception:
      +                continue
      +
      +            status = subprocess.run(
      +                ["git", "status", "--porcelain"],
      +                capture_output=True, text=True, timeout=5, cwd=str(entry),
      +            )
      +            if status.stdout.strip():
      +                continue
      +
      +            branch_result = subprocess.run(
      +                ["git", "branch", "--show-current"],
      +                capture_output=True, text=True, timeout=5, cwd=str(entry),
      +            )
      +            branch = branch_result.stdout.strip()
      +            subprocess.run(
      +                ["git", "worktree", "remove", str(entry), "--force"],
      +                capture_output=True, text=True, timeout=15, cwd=str(git_repo),
      +            )
      +            if branch:
      +                subprocess.run(
      +                    ["git", "branch", "-D", branch],
      +                    capture_output=True, text=True, timeout=10, cwd=str(git_repo),
      +                )
      +
      +        assert not Path(info["path"]).exists()
      +
      +    def test_keeps_recent_worktree(self, git_repo):
      +        """Recent worktrees should NOT be pruned."""
      +        import time
      +
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Don't modify mtime — it's recent
      +        worktrees_dir = git_repo / ".worktrees"
      +        cutoff = time.time() - (24 * 3600)
      +
      +        pruned = False
      +        for entry in worktrees_dir.iterdir():
      +            if not entry.is_dir() or not entry.name.startswith("hermes-"):
      +                continue
      +            mtime = entry.stat().st_mtime
      +            if mtime > cutoff:
      +                continue  # Too recent
      +            pruned = True
      +
      +        assert not pruned
      +        assert Path(info["path"]).exists()
      +
      +    def test_keeps_dirty_old_worktree(self, git_repo):
      +        """Old worktrees with uncommitted changes should NOT be pruned."""
      +        import time
      +
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # Make it dirty
      +        (Path(info["path"]) / "dirty.txt").write_text("uncommitted")
      +        subprocess.run(
      +            ["git", "add", "dirty.txt"],
      +            cwd=info["path"], capture_output=True,
      +        )
      +
      +        # Make it old
      +        old_time = time.time() - (25 * 3600)
      +        os.utime(info["path"], (old_time, old_time))
      +
      +        # Check if it would be pruned
      +        status = subprocess.run(
      +            ["git", "status", "--porcelain"],
      +            capture_output=True, text=True, cwd=info["path"],
      +        )
      +        has_changes = bool(status.stdout.strip())
      +        assert has_changes  # Should be dirty → not pruned
      +        assert Path(info["path"]).exists()
      +
      +
      +class TestEdgeCases:
      +    """Test edge cases for robustness."""
      +
      +    def test_no_commits_repo(self, tmp_path):
      +        """Worktree creation should fail gracefully on a repo with no commits."""
      +        repo = tmp_path / "empty-repo"
      +        repo.mkdir()
      +        subprocess.run(["git", "init"], cwd=str(repo), capture_output=True)
      +
      +        info = _setup_worktree(str(repo))
      +        assert info is None  # Should fail gracefully
      +
      +    def test_not_a_git_repo(self, tmp_path):
      +        """Repo detection should return None for non-git directories."""
      +        bare = tmp_path / "not-git"
      +        bare.mkdir()
      +        root = _git_repo_root(cwd=str(bare))
      +        assert root is None
      +
      +    def test_worktrees_dir_already_exists(self, git_repo):
      +        """Should work fine if .worktrees/ already exists."""
      +        (git_repo / ".worktrees").mkdir(exist_ok=True)
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +        assert Path(info["path"]).exists()
      +
      +
      +class TestCLIFlagLogic:
      +    """Test the flag/config OR logic from main()."""
      +
      +    def test_worktree_flag_triggers(self):
      +        """--worktree flag should trigger worktree creation."""
      +        worktree = True
      +        w = False
      +        config_worktree = False
      +        use_worktree = worktree or w or config_worktree
      +        assert use_worktree
      +
      +    def test_w_flag_triggers(self):
      +        """-w flag should trigger worktree creation."""
      +        worktree = False
      +        w = True
      +        config_worktree = False
      +        use_worktree = worktree or w or config_worktree
      +        assert use_worktree
      +
      +    def test_config_triggers(self):
      +        """worktree: true in config should trigger worktree creation."""
      +        worktree = False
      +        w = False
      +        config_worktree = True
      +        use_worktree = worktree or w or config_worktree
      +        assert use_worktree
      +
      +    def test_none_set_no_trigger(self):
      +        """No flags and no config should not trigger."""
      +        worktree = False
      +        w = False
      +        config_worktree = False
      +        use_worktree = worktree or w or config_worktree
      +        assert not use_worktree
      +
      +
      +class TestTerminalCWDIntegration:
      +    """Test that TERMINAL_CWD is correctly set to the worktree path."""
      +
      +    def test_terminal_cwd_set(self, git_repo):
      +        """After worktree setup, TERMINAL_CWD should point to the worktree."""
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # This is what main() does:
      +        os.environ["TERMINAL_CWD"] = info["path"]
      +        assert os.environ["TERMINAL_CWD"] == info["path"]
      +        assert Path(os.environ["TERMINAL_CWD"]).exists()
      +
      +        # Clean up env
      +        del os.environ["TERMINAL_CWD"]
      +
      +    def test_terminal_cwd_is_valid_git_repo(self, git_repo):
      +        """The TERMINAL_CWD worktree should be a valid git working tree."""
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        result = subprocess.run(
      +            ["git", "rev-parse", "--is-inside-work-tree"],
      +            capture_output=True, text=True, cwd=info["path"],
      +        )
      +        assert result.stdout.strip() == "true"
      +
      +
      +class TestSystemPromptInjection:
      +    """Test that the agent gets worktree context in its system prompt."""
      +
      +    def test_prompt_note_format(self, git_repo):
      +        """Verify the system prompt note contains all required info."""
      +        info = _setup_worktree(str(git_repo))
      +        assert info is not None
      +
      +        # This is what main() does:
      +        wt_note = (
      +            f"\n\n[System note: You are working in an isolated git worktree at "
      +            f"{info['path']}. Your branch is `{info['branch']}`. "
      +            f"Changes here do not affect the main working tree or other agents. "
      +            f"Remember to commit and push your changes, and create a PR if appropriate. "
      +            f"The original repo is at {info['repo_root']}.]"
      +        )
      +
      +        assert info["path"] in wt_note
      +        assert info["branch"] in wt_note
      +        assert info["repo_root"] in wt_note
      +        assert "isolated git worktree" in wt_note
      +        assert "commit and push" in wt_note
    • website/docs/reference/cli-commands.md
      @@ -22,6 +22,7 @@ These are commands you run from your shell.
       | `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
       | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
       | `hermes chat --verbose` | Enable verbose/debug output |
      +| `hermes --worktree` / `-w` | Start in an isolated git worktree (for parallel agents) |
       
       ### Provider & Model Management
       
    • website/docs/user-guide/cli.md
      @@ -33,6 +33,10 @@ hermes --resume <session_id>  # Resume a specific session by ID (-r)
       
       # Verbose mode (debug output)
       hermes chat --verbose
      +
      +# Isolated git worktree (for running multiple agents in parallel)
      +hermes -w                         # Interactive mode in worktree
      +hermes -w -q "Fix issue #123"     # Single query in worktree
       ```
       
       ## Interface Layout
    • website/docs/user-guide/configuration.md
      @@ -407,6 +407,26 @@ memory:
         user_char_limit: 1375     # ~500 tokens
       ```
       
      +## Git Worktree Isolation
      +
      +Enable isolated git worktrees for running multiple agents in parallel on the same repo:
      +
      +```yaml
      +worktree: true    # Always create a worktree (same as hermes -w)
      +# worktree: false # Default — only when -w flag is passed
      +```
      +
      +When enabled, each CLI session creates a fresh worktree under `.worktrees/` with its own branch. Agents can edit files, commit, push, and create PRs without interfering with each other. Clean worktrees are removed on exit; dirty ones are kept for manual recovery.
      +
      +You can also list gitignored files to copy into worktrees via `.worktreeinclude` in your repo root:
      +
      +```
      +# .worktreeinclude
      +.env
      +.venv/
      +node_modules/
      +```
      +
       ## Context Compression
       
       ```yaml
  • #44446 fix(cli): use subprocess on Windows for dashboard profile re-exec (#44282)

    merged to main

    files: hermes_cli/main.py

2 PRs closed without merging

From the close comments: done elsewhere 1 · duplicate 1

  • #44355 fix(cli): skip dashboard re-exec on Windows to avoid execvpe segfault · done elsewhere → 3aa16a7
  • #44393 fix(cli): serve profile dashboard in-place on Windows instead of re-exec · duplicate → #44355
Closed issues · 5
  • Closed#652Feature: Git Worktree Isolation for Parallel CLI Sessions (`hermes -w`)
  • Closed#44282[Bug]: os.execvpe() crashes with STATUS_ACCESS_VIOLATION on Windows (Python 3.14.4), breaking profile dashboard
  • Closed#44309[Bug]: Desktop-spawned backend crashes with ACCESS VIOLATION when using non-default profile + `HERMES_DESKTOP=1`
  • Closed#44352[Bug]: After upgrading to Hermes Desktop v0.16.0, the Desktop app's secondary profiles (non-default) fail to connect and persistently show "Gateway Offline" in the UI. The default profile works fine.
  • Closed#44530[Bug]: Windows: desktop GUI cannot start non-default profiles after EXE reinstall
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.11: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#654, #44446
Closed issues
5
Attempts
2 PRs closed without merging
Fix link checked
12 Jun 2026

For your agent

The same facts, typed.