# NousResearch/hermes-agent · Desktop multi-profile bugs, crashes, and worktree isolation

## Open now

- 2 issues, 2 PRs open on GitHub.
- #43953: \[Bug\]: Hermes Desktop crashes when running multiple profile sessions at the same time
- #46303: \[Bug\]: Concurrent sessions cross-contaminate (shared memory injection + shared git worktree) with no isolation or awareness
- PR #44089: fix(desktop): keep silent profile turns alive past idle reap
- PR #80034: fix(status): show live sessions when no concurrency cap is set

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.

## TL;DR

- 13 member(s): 2 open issue(s), 5 closed issue(s), 2 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #654. Verify it addresses your case.
- **Recommended:** read PR #654 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-654,pr-44446`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #652: Feature: Git Worktree Isolation for Parallel CLI Sessions (\`hermes -w\`)
- #44282: \[Bug\]: os.execvpe() crashes with STATUS_ACCESS_VIOLATION on Windows (Python 3.14.4), breaking profile dashboard
- #44309: \[Bug\]:  Desktop-spawned backend crashes with ACCESS VIOLATION when using non-default profile + \`HERMES_DESKTOP=1\`
- #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.
- #44530: \[Bug\]: Windows: desktop GUI cannot start non-default profiles after EXE reinstall

## Prior & competing PRs

- **#654: feat: git worktree isolation for parallel CLI sessions (--worktree / -w)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `AGENTS.md`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     # ============================================================================
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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")
         
    ```

    ```diff
    @@ -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)
     
         # =========================================================================
    ```

    ```diff
    @@ -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
         
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#44355: fix(cli): skip dashboard re-exec on Windows to avoid execvpe segfault**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3aa16a7ab`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#44446: fix(cli): use subprocess on Windows for dashboard profile re-exec (#44282)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`
- **#44089: fix(desktop): keep silent profile turns alive past idle reap**. open
  - maintainer direction: category `stale_abandoned`; implication `active`
  - files: `apps/desktop/src/store/session-watchdog.test.ts`, `apps/desktop/src/store/session.ts`
- **#44393: fix(cli): serve profile dashboard in-place on Windows instead of re-exec**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#44355`, `#44355`
- **#80034: fix(status): show live sessions when no concurrency cap is set**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `51e67babca`, `#46303`
  - files: `hermes_cli/active_sessions.py`, `hermes_cli/status.py`, `tests/hermes_cli/test_active_sessions.py`, `tests/hermes_cli/test_status.py`

## Fix status

- Merged to main in PR #654 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-12`.
- Subsystem: `agent`
