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