{"repo":"NousResearch/hermes-agent","public_id":"20561","display_label":"Docker sandbox cleanup gaps and container accumulation","summary":"Multiple issues and fixes addressing Docker containers and sandbox directories not being properly cleaned up on /stop, across session boundaries, and during orphan reaping, leading to disk and container accumulation.","subsystem":"environments","counts":{"members":9,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33645},"resolution":{"state":"partially_resolved","by":"pr-33645"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T01:49:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":20561,"pr_number":33645,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28807,"pr_number":33645,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":20561,"title":"Bug: Docker containers accumulate - /stop doesn't clean, multiple per session, async race","url":null,"state":"closed"},{"kind":"issue","number":39489,"title":"/stop command doesn't clean up Docker sandbox environments (only kills background processes)","url":null,"state":"open"},{"kind":"issue","number":44114,"title":"sandbox dirs under ~/.hermes/sandboxes/docker/ are never deleted","url":null,"state":"open"},{"kind":"pr","number":20565,"title":"fix(docker): make terminal cleanup remove containers reliably","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5c2170a7c","number":0},{"kind":"pr","value":"#20561","number":20561},{"kind":"pr","value":"#20569","number":20569},{"kind":"pr","value":"#20561","number":20561}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":20569,"title":"fix(docker): add wait parameter to cleanup and call from /stop command","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33645","number":33645},{"kind":"pr","value":"#33645","number":33645},{"kind":"pr","value":"#39489","number":39489},{"kind":"pr","value":"#39489","number":39489},{"kind":"pr","value":"#20561","number":20561},{"kind":"pr","value":"#33645","number":33645},{"kind":"pr","value":"#39489","number":39489}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33645,"title":"fix(docker): container reuse + bounded-sync cleanup + orphan reaper (#20561)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","tests/conftest.py","tests/tools/test_docker_environment.py","tests/tools/test_docker_orphan_reaper_integration.py","tests/tools/test_terminal_config_env_sync.py","tools/environments/docker.py","tools/terminal_tool.py","website/docs/user-guide/configuration.md","website/docs/user-guide/docker.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -576,6 +576,8 @@ def load_cli_config() -> Dict[str, Any]:\n         \"docker_env\": \"TERMINAL_DOCKER_ENV\",\n         \"docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n         \"docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n+        \"docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n+        \"docker_orphan_reaper\": \"TERMINAL_DOCKER_ORPHAN_REAPER\",\n         \"sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n         # Persistent shell (non-local backends)\n         \"persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\","]},{"file":"gateway/run.py","hunks":["@@ -831,6 +831,8 @@ def _reload_runtime_env_preserving_config_authority() -> None:\n                 \"docker_env\": \"TERMINAL_DOCKER_ENV\",\n                 \"docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n                 \"docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n+                \"docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n+                \"docker_orphan_reaper\": \"TERMINAL_DOCKER_ORPHAN_REAPER\",\n                 \"sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n                 \"persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n             }"]},{"file":"hermes_cli/config.py","hunks":["@@ -5482,6 +5482,8 @@ def set_config_value(key: str, value: str):\n         \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n         \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n         \"terminal.docker_run_as_host_user\": \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n+        \"terminal.docker_persist_across_processes\": \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n+        \"terminal.docker_orphan_reaper\": \"TERMINAL_DOCKER_ORPHAN_REAPER\",\n         \"terminal.docker_env\": \"TERMINAL_DOCKER_ENV\",\n         # terminal.cwd intentionally excluded — CLI resolves at runtime,\n         # gateway bridges it in gateway/run.py. Persisting to .env causes"]},{"file":"tests/conftest.py","hunks":["@@ -227,6 +227,8 @@ def _looks_like_credential(name: str) -> bool:\n     \"TERMINAL_CONTAINER_DISK\",\n     \"TERMINAL_CONTAINER_MEMORY\",\n     \"TERMINAL_CONTAINER_PERSISTENT\",\n+    \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\",\n+    \"TERMINAL_DOCKER_ORPHAN_REAPER\",\n     \"TERMINAL_DOCKER_RUN_AS_HOST_USER\",\n     \"BROWSER_CDP_URL\",\n     \"CAMOFOX_URL\","]},{"file":"tests/tools/test_docker_environment.py","hunks":["@@ -203,25 +203,43 @@ def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path):\n \n \n def test_non_persistent_cleanup_removes_container(monkeypatch):\n-    \"\"\"When persistent=false, cleanup() must schedule docker stop + rm.\"\"\"\n+    \"\"\"When persist_across_processes=false, cleanup() must docker stop AND\n+    docker rm so containers don't leak across hermes processes.\n+\n+    Updated for issue #20561: the previous implementation used fire-and-forget\n+    ``subprocess.Popen(\"... &\", shell=True)`` which raced with parent exit;\n+    the new implementation uses ``subprocess.run`` on a daemon thread with\n+    bounded timeouts. See test_cleanup_with_persist_disabled_stops_and_rms\n+    for the full behavior contract.\n+    \"\"\"\n     monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n-    calls = _mock_subprocess_run(monkeypatch)\n-\n-    popen_cmds = []\n-    monkeypatch.setattr(\n-        docker_env.subprocess, \"Popen\",\n-        lambda cmd, **kw: (popen_cmds.append(cmd), type(\"P\", (), {\"poll\": lambda s: 0, \"wait\": lambda s, **k: None, \"returncode\": 0, \"stdout\": iter([]), \"stdin\": None})())[1],\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    # Run the worker thread synchronously so assertions can observe its work.\n+    import threading\n+    monkeypatch.setattr(threading, \"Thread\", _FakeThread)\n+\n+    env = docker_env.DockerEnvironment(\n+        image=\"python:3.11\", cwd=\"/root\", timeout=60,\n+        task_id=\"ephemeral-task\", persistent_filesystem=False,\n+        persist_across_processes=False,\n     )\n-\n-    env = _make_dummy_env(persistent_filesystem=False, task_id=\"ephemeral-task\")\n-    assert env._container_id\n     container_id = env._container_id\n+    assert container_id\n+\n+    # Capture cleanup-time docker calls (everything before this was init).\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n \n+    def _capture(cmd, **kw):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kw))\n+        return real_run(cmd, **kw)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capture)\n     env.cleanup()\n \n-    # Should have stop and rm calls via Popen\n-    stop_cmds = [c for c in popen_cmds if container_id in str(c) and \"stop\" in str(c)]\n-    assert len(stop_cmds) >= 1, f\"cleanup() should schedule docker stop for {container_id}\"\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and c[0][1:2] == [\"stop\"]]\n+    assert stops, f\"cleanup() should docker stop {container_id}; got {cleanup_calls}\"\n \n \n class _FakePopen:","@@ -514,3 +532,839 @@ def test_run_as_host_user_warns_and_skips_when_no_posix_ids(monkeypatch, caplog)\n         \"does not expose POSIX uid/gid\" in rec.getMessage()\n         for rec in caplog.records\n     ), \"expected a warning when POSIX ids are unavailable\"\n+\n+\n+# ── Docker labels (issue #20561) ──────────────────────────────────\n+\n+\n+def _run_args_from_calls(calls):\n+    \"\"\"Pull the argv list passed to the first ``docker run`` invocation.\"\"\"\n+    run_calls = [\n+        c for c in calls\n+        if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"\n+    ]\n+    assert run_calls, \"docker run should have been called\"\n+    return run_calls[0][0]\n+\n+\n+def _labels_in_run_args(run_args):\n+    \"\"\"Return the set of ``key=value`` strings passed via ``--label``.\"\"\"\n+    return {\n+        run_args[i + 1]\n+        for i, flag in enumerate(run_args[:-1])\n+        if flag == \"--label\"\n+    }\n+\n+\n+def test_run_command_tags_hermes_agent_label(monkeypatch):\n+    \"\"\"Every container hermes-agent starts must carry the hermes-agent=1 label\n+    so the orphan reaper (and external operators) can identify them with a\n+    single ``docker ps --filter label=hermes-agent=1`` call. Regression test\n+    for issue #20561 — without the label there is no global sweep target.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    calls = _mock_subprocess_run(monkeypatch)\n+\n+    _make_dummy_env(task_id=\"my-task\")\n+\n+    labels = _labels_in_run_args(_run_args_from_calls(calls))\n+    assert \"hermes-agent=1\" in labels, (\n+        f\"hermes-agent=1 label missing; got labels: {sorted(labels)}\"\n+    )\n+\n+\n+def test_run_command_tags_task_and_profile_labels(monkeypatch):\n+    \"\"\"task_id and the active profile name are surfaced as labels so future\n+    cross-process reuse logic can filter to a specific (task, profile) pair\n+    without parsing container names. Profile resolution uses the helper that\n+    returns ``\"default\"`` for the root Hermes home.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"research-bot\")\n+    calls = _mock_subprocess_run(monkeypatch)\n+\n+    _make_dummy_env(task_id=\"kanban-42\")\n+\n+    labels = _labels_in_run_args(_run_args_from_calls(calls))\n+    assert \"hermes-task-id=kanban-42\" in labels, (\n+        f\"hermes-task-id=kanban-42 missing; got: {sorted(labels)}\"\n+    )\n+    assert \"hermes-profile=research-bot\" in labels, (\n+        f\"hermes-profile=research-bot missing; got: {sorted(labels)}\"\n+    )\n+\n+\n+def test_label_sanitizer_rejects_invalid_characters():\n+    \"\"\"Docker label values must be alnum + ``_.-`` and ≤63 chars. Profile or\n+    task names containing slashes, colons, or unicode would otherwise emit\n+    invalid labels that round-trip badly through ``docker ps --filter``.\"\"\"\n+    assert docker_env._sanitize_label_value(\"plain-name_1.0\") == \"plain-name_1.0\"\n+    assert docker_env._sanitize_label_value(\"with/slash\") == \"with_slash\"\n+    assert docker_env._sanitize_label_value(\"with:colon\") == \"with_colon\"\n+    assert docker_env._sanitize_label_value(\"emoji-😀-here\") == \"emoji-_-here\"\n+    # Empty / non-string inputs must collapse to a queryable token, not \"\".\n+    assert docker_env._sanitize_label_value(\"\") == \"unknown\"\n+    assert docker_env._sanitize_label_value(None) == \"unknown\"  # type: ignore[arg-type]\n+    # >63 chars must truncate, not error.\n+    long_value = \"x\" * 100\n+    assert len(docker_env._sanitize_label_value(long_value)) == 63\n+\n+\n+def test_run_command_sanitizes_unsafe_task_id(monkeypatch):\n+    \"\"\"A task_id containing characters Docker rejects in label values must be\n+    sanitized before reaching ``docker run --label``; otherwise the daemon\n+    refuses the run with an inscrutable error and the agent's first command\n+    blows up.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    calls = _mock_subprocess_run(monkeypatch)\n+\n+    _make_dummy_env(task_id=\"task/with:weird*chars\")\n+\n+    labels = _labels_in_run_args(_run_args_from_calls(calls))\n+    # Each non-OK character becomes an underscore; the safe chars survive.\n+    assert \"hermes-task-id=task_with_weird_chars\" in labels, (\n+        f\"sanitized task-id label missing; got: {sorted(labels)}\"\n+    )\n+\n+\n+def test_labels_attribute_populated_after_init(monkeypatch):\n+    \"\"\"``self._labels`` must be set to the same key/value pairs that went onto\n+    docker run, so subsequent reuse / reaper paths can match without re-running\n+    the sanitizer or re-importing the profile module.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+\n+    env = _make_dummy_env(task_id=\"abc\")\n+\n+    assert env._labels == {\n+        \"hermes-agent\": \"1\",\n+        \"hermes-task-id\": \"abc\",\n+        \"hermes-profile\": \"default\",\n+    }\n+\n+\n+# ── Cross-process container reuse (issue #20561) ──────────────────\n+\n+\n+def _mock_subprocess_run_with_reuse(monkeypatch, ps_state: str | None,\n+                                     start_succeeds: bool = True):\n+    \"\"\"Reuse-aware subprocess.run mock.\n+\n+    ``ps_state`` controls what ``docker ps -a --filter ...`` returns:\n+      * ``None`` → no match (empty stdout). Forces a fresh ``docker run``.\n+      * ``\"running\"`` / ``\"exited\"`` / ... → emit ``CID\\\\tSTATE`` so the reuse\n+        path picks it up. ``\"running\"`` skips ``docker start``; other states\n+        trigger ``docker start`` (which can be forced to fail via\n+        ``start_succeeds=False``).\n+\n+    Returns the captured call list so the test can verify which docker\n+    commands actually ran.\n+    \"\"\"\n+    calls = []\n+\n+    def _run(cmd, **kwargs):\n+        calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        if isinstance(cmd, list) and len(cmd) >= 2:\n+            sub = cmd[1]\n+            if sub == \"version\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"Docker version\", stderr=\"\")\n+            if sub == \"ps\":\n+                if ps_state is None:\n+                    return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+                return subprocess.CompletedProcess(\n+                    cmd, 0, stdout=f\"reused-cid\\t{ps_state}\\n\", stderr=\"\",\n+                )\n+            if sub == \"start\":\n+                if not start_succeeds:\n+                    # Real subprocess.run with check=True raises on non-zero exit;\n+                    # mirror that so the production code's except clause fires.\n+                    raise subprocess.CalledProcessError(1, cmd, output=\"\", stderr=\"no such container\")\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"reused-cid\\n\", stderr=\"\")\n+            if sub == \"run\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"fresh-cid\\n\", stderr=\"\")\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    return calls\n+\n+\n+def test_reuse_attaches_to_running_container_without_docker_run(monkeypatch):\n+    \"\"\"When a labeled container is already ``running``, the reuse probe\n+    must pick it up and skip ``docker run`` entirely. Regression for the\n+    issue #20561 root cause: every Hermes process spawning a new container\n+    despite docs claiming \"ONE long-lived container shared across sessions\".\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    calls = _mock_subprocess_run_with_reuse(monkeypatch, ps_state=\"running\")\n+\n+    env = _make_dummy_env(task_id=\"reuse-test\")\n+\n+    # The reuse path must populate _container_id from the ps probe output.\n+    assert env._container_id == \"reused-cid\", (\n+        f\"expected reused container id, got {env._container_id!r}\"\n+    )\n+    # And it must NOT have run `docker run`.\n+    run_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"]\n+    assert not run_invocations, (\n+        f\"docker run should be skipped on reuse, got: {run_invocations}\"\n+    )\n+    # And it must have NOT issued a `docker start` for an already-running container.\n+    start_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"start\"]\n+    assert not start_invocations, (\n+        f\"docker start should be skipped when container already running, got: {start_invocations}\"\n+    )\n+\n+\n+def test_reuse_starts_stopped_container_before_attaching(monkeypatch):\n+    \"\"\"A labeled container in ``exited`` state must be restarted via\n+    ``docker start`` before the new Hermes process uses it. Without this\n+    step, ``docker exec`` against a stopped container errors out and the\n+    first agent command fails opaquely.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    calls = _mock_subprocess_run_with_reuse(monkeypatch, ps_state=\"exited\")\n+\n+    env = _make_dummy_env(task_id=\"reuse-stopped\")\n+\n+    assert env._container_id == \"reused-cid\"\n+    start_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"start\"]\n+    assert start_invocations, \"expected docker start for exited container\"\n+    run_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"]\n+    assert not run_invocations, \"should not docker run when reusing an exited container\"\n+\n+\n+def test_reuse_falls_back_to_fresh_run_when_start_fails(monkeypatch):\n+    \"\"\"If ``docker start`` on the matched container fails (container was\n+    removed between probe and start, daemon paused, etc.), the code must\n+    silently fall through to a fresh ``docker run`` rather than leaving the\n+    user with a broken environment. Defensive recovery — the probe is best-\n+    effort, not authoritative.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    calls = _mock_subprocess_run_with_reuse(\n+        monkeypatch, ps_state=\"exited\", start_succeeds=False,\n+    )\n+\n+    env = _make_dummy_env(task_id=\"reuse-broken-start\")\n+\n+    # docker start should be attempted then fail; code falls through to run.\n+    assert env._container_id == \"fresh-cid\", (\n+        f\"expected fresh container id after fallback, got {env._container_id!r}\"\n+    )\n+    run_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"run\"]\n+    assert run_invocations, \"fallback to fresh docker run must happen on start failure\"\n+\n+\n+def test_no_reuse_when_persist_across_processes_disabled(monkeypatch):\n+    \"\"\"Opt-out path: ``persist_across_processes=False`` skips the ps probe\n+    entirely and always starts a fresh container, matching the pre-fix\n+    behavior for users who want hard per-process isolation.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    # ps_state=running would trigger reuse if the probe ran — assert it doesn't.\n+    calls = _mock_subprocess_run_with_reuse(monkeypatch, ps_state=\"running\")\n+\n+    env = docker_env.DockerEnvironment(\n+        image=\"python:3.11\", cwd=\"/root\", timeout=60,\n+        task_id=\"no-reuse\", persist_across_processes=False,\n+    )\n+\n+    # Must NOT have issued docker ps (the probe is gated by the flag).\n+    ps_invocations = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"ps\"]\n+    assert not ps_invocations, (\n+        f\"docker ps probe should be skipped when persist_across_processes=False, got: {ps_invocations}\"\n+    )\n+    # Should have started a fresh container.\n+    assert env._container_id == \"fresh-cid\"\n+\n+\n+def test_find_reusable_container_prefers_running_over_stopped(monkeypatch):\n+    \"\"\"When the probe returns multiple matches (shouldn't normally happen,\n+    but can after a crash leaves stale duplicates), a ``running`` container\n+    is preferred over any stopped one. The duplicate gets reaped later by\n+    the orphan reaper; we don't try to be heroic about it here.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+\n+    def _run(cmd, **kwargs):\n+        if isinstance(cmd, list) and len(cmd) >= 2:\n+            if cmd[1] == \"version\":\n+                return subprocess.CompletedProcess(cmd, 0, stdout=\"ok\", stderr=\"\")\n+            if cmd[1] == \"ps\":\n+                # Two matches: stopped first, running second.\n+                return subprocess.CompletedProcess(\n+                    cmd, 0,\n+                    stdout=\"stopped-cid\\texited\\nrunning-cid\\trunning\\n\",\n+                    stderr=\"\",\n+                )\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"fresh-cid\\n\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+\n+    env = _make_dummy_env(task_id=\"dup-match\")\n+    assert env._container_id == \"running-cid\", (\n+        f\"running container should win over stopped duplicate, got {env._container_id!r}\"\n+    )\n+\n+\n+# ── Cleanup correctness (issue #20561) ────────────────────────────\n+\n+\n+class _FakeThread:\n+    \"\"\"Stand-in for threading.Thread that captures target/args and calls\n+    target() synchronously when .start() runs, so cleanup behavior is\n+    observable without actually backgrounding subprocess calls.\"\"\"\n+\n+    def __init__(self, target=None, daemon=None, name=None):\n+        self._target = target\n+        self.daemon = daemon\n+        self.name = name\n+        self._done = False\n+\n+    def start(self):\n+        if self._target is not None:\n+            self._target()\n+        self._done = True\n+\n+    def is_alive(self):\n+        return not self._done\n+\n+    def join(self, timeout=None):\n+        self._done = True\n+\n+\n+def _install_fake_thread(monkeypatch):\n+    import threading\n+    monkeypatch.setattr(threading, \"Thread\", _FakeThread)\n+\n+\n+def test_cleanup_with_persist_is_noop_for_container(monkeypatch):\n+    \"\"\"``persist_across_processes=True`` (default) cleanup must NEITHER stop\n+    NOR remove the container — the docs promise \"ONE long-lived container\n+    shared across sessions\", and any docker stop would kill background\n+    processes inside the container (npm watchers, pytest watchers, etc.).\n+\n+    Resource reclamation in this mode happens via the orphan reaper on next\n+    Hermes startup, not on graceful exit. Issue #20561 — the first iteration\n+    of this PR did docker stop here, which Ben caught as contradicting the\n+    \"ONE long-lived container\" semantics.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    env = _make_dummy_env(task_id=\"cleanup-persist\", persistent_filesystem=False)\n+    # Default persist_across_processes=True.\n+    container_id = env._container_id\n+    assert container_id\n+\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n+\n+    def _capturing_run(cmd, **kwargs):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        return real_run(cmd, **kwargs)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capturing_run)\n+\n+    env.cleanup()\n+\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"stop\"]\n+    rms = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"rm\"]\n+    assert not stops, (\n+        f\"docker stop must NOT be called when persist_across_processes=True; \"\n+        f\"container has to stay running so background processes survive. \"\n+        f\"Got: {stops}\"\n+    )\n+    assert not rms, (\n+        f\"docker rm must NOT be called when persist_across_processes=True; \"\n+        f\"reuse would be impossible. Got: {rms}\"\n+    )\n+    # The in-process handle must still be cleared so the next __init__\n+    # re-probes via labels (and reuses the still-running container).\n+    assert env._container_id is None, (\n+        \"in-process container_id should be cleared even in no-op cleanup\"\n+    )\n+\n+\n+def test_cleanup_force_remove_stops_and_rms_even_in_persist_mode(monkeypatch):\n+    \"\"\"``cleanup(force_remove=True)`` must stop AND rm the container even\n+    when ``persist_across_processes=True``. This is the explicit-teardown\n+    path for ``/reset``, ``cleanup_vm(task_id, force_remove=True)``, and any\n+    future caller that wants a guaranteed fresh container.\n+\n+    Without this kwarg, callers in persist mode would have no way to force a\n+    fresh container without also flipping the global config — too coarse for\n+    a per-task reset.\n+    \"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    env = _make_dummy_env(task_id=\"cleanup-force\", persistent_filesystem=False)\n+    assert env._container_id\n+\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n+\n+    def _capturing_run(cmd, **kwargs):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        return real_run(cmd, **kwargs)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capturing_run)\n+\n+    env.cleanup(force_remove=True)\n+\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"stop\"]\n+    rms = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"rm\"]\n+    assert stops, f\"force_remove must docker stop; got: {cleanup_calls}\"\n+    assert rms, f\"force_remove must docker rm; got: {cleanup_calls}\"\n+\n+\n+def test_cleanup_vm_default_honors_persist_mode(monkeypatch):\n+    \"\"\"``cleanup_vm(task_id)`` without ``force_remove=True`` must be a no-op\n+    for a persist-mode container.\n+\n+    Regression for the bug Ben caught after commit 4: ``AIAgent.close()``\n+    (which is called from ``tui_gateway/server.py`` on session.close, from\n+    ``gateway/run.py`` on per-session teardown, and from per-turn cleanup)\n+    calls ``cleanup_vm(task_id)``. If that defaulted to ``force_remove=True``\n+    we'd tear down the container on every TUI session close, defeating the\n+    \"ONE long-lived container shared across sessions\" contract.\n+    \"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    from tools import terminal_tool\n+\n+    env = _make_dummy_env(task_id=\"session-close-test\")\n+    container_id = env._container_id\n+    terminal_tool._active_environments[\"session-close-test\"] = env\n+\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n+\n+    def _capturing_run(cmd, **kwargs):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        return real_run(cmd, **kwargs)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capturing_run)\n+\n+    try:\n+        terminal_tool.cleanup_vm(\"session-close-test\")\n+    finally:\n+        terminal_tool._active_environments.pop(\"session-close-test\", None)\n+\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"stop\"]\n+    rms = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"rm\"]\n+    assert not stops, (\n+        f\"cleanup_vm() default must not docker stop a persist-mode container; \"\n+        f\"got: {stops}\"\n+    )\n+    assert not rms, (\n+        f\"cleanup_vm() default must not docker rm a persist-mode container; \"\n+        f\"got: {rms}\"\n+    )\n+\n+\n+def test_cleanup_vm_force_remove_tears_down_persist_container(monkeypatch):\n+    \"\"\"``cleanup_vm(task_id, force_remove=True)`` tears down a persist-mode\n+    container — the explicit-teardown path for ``/reset``-style flows.\n+\n+    Also pins the runtime-signature-inspection plumbing: the kwarg must\n+    actually flow through ``cleanup_vm`` into the backend's ``cleanup()``.\n+    \"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    from tools import terminal_tool\n+\n+    env = _make_dummy_env(task_id=\"explicit-teardown-test\")\n+    terminal_tool._active_environments[\"explicit-teardown-test\"] = env\n+\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n+\n+    def _capturing_run(cmd, **kwargs):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        return real_run(cmd, **kwargs)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capturing_run)\n+\n+    try:\n+        terminal_tool.cleanup_vm(\"explicit-teardown-test\", force_remove=True)\n+    finally:\n+        terminal_tool._active_environments.pop(\"explicit-teardown-test\", None)\n+\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"stop\"]\n+    rms = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"rm\"]\n+    assert stops, f\"force_remove must reach docker stop; got: {cleanup_calls}\"\n+    assert rms, f\"force_remove must reach docker rm; got: {cleanup_calls}\"\n+\n+\n+def test_cleanup_with_persist_disabled_stops_and_rms(monkeypatch):\n+    \"\"\"``persist_across_processes=False`` cleanup must docker stop AND docker\n+    rm so containers don't leak. Crucially, this runs regardless of the\n+    ``persistent_filesystem`` setting — the original code only rm'd when\n+    ``not self._persistent``, which meant the default-on ``container_persistent:\n+    true`` users (the documented happy path) leaked Exited containers forever.\n+    Issue #20561 root-cause fix.\"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    # Note: persistent_filesystem=True (the prior-leak scenario) + the new\n+    # cross-process toggle OFF must still result in a clean rm.\n+    env = docker_env.DockerEnvironment(\n+        image=\"python:3.11\", cwd=\"/root\", timeout=60,\n+        task_id=\"cleanup-no-persist\", persistent_filesystem=True,\n+        persist_across_processes=False,\n+    )\n+\n+    cleanup_calls = []\n+    real_run = docker_env.subprocess.run\n+\n+    def _capturing_run(cmd, **kwargs):\n+        cleanup_calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        return real_run(cmd, **kwargs)\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _capturing_run)\n+\n+    env.cleanup()\n+\n+    stops = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"stop\"]\n+    rms = [c for c in cleanup_calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == \"rm\"]\n+    assert stops, \"expected docker stop\"\n+    assert rms, (\n+        \"docker rm MUST run when persist_across_processes=False, even with \"\n+        \"persistent_filesystem=True — that gating was the leak source in #20561.\"\n+    )\n+\n+\n+def test_cleanup_uses_subprocess_run_not_detached_shell(monkeypatch):\n+    \"\"\"The pre-fix code used ``subprocess.Popen(\"... &\", shell=True)`` which\n+    raced with parent-process exit and silently dropped cleanup work. The\n+    new code must use ``subprocess.run`` with bounded ``timeout=`` so the\n+    work actually completes within the process lifetime.\n+\n+    Asserts cleanup never reaches into shell-mode Popen. Uses\n+    ``force_remove=True`` so cleanup actually issues docker calls — the\n+    default persist-mode path is now a no-op (commit 4) and would trivially\n+    pass this assertion without exercising the docker code at all.\n+    \"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    def _forbidden_popen(*args, **kwargs):\n+        raise AssertionError(\n+            f\"cleanup must not use subprocess.Popen anymore (issue #20561); \"\n+            f\"got args={args} kwargs={kwargs}\"\n+        )\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"Popen\", _forbidden_popen)\n+\n+    env = _make_dummy_env(task_id=\"no-popen-cleanup\")\n+    env.cleanup(force_remove=True)  # must not raise\n+\n+\n+def test_wait_for_cleanup_returns_true_when_no_thread_started():\n+    \"\"\"``wait_for_cleanup`` must be a no-op when ``cleanup`` was never called\n+    (or the env has no live cleanup thread) — atexit calls it unconditionally\n+    across all active envs, so a False return would falsely flag healthy\n+    shutdowns.\"\"\"\n+    env = docker_env.DockerEnvironment.__new__(docker_env.DockerEnvironment)\n+    # No _cleanup_thread set — simulates an env that was never cleanup()'d.\n+    assert env.wait_for_cleanup(timeout=1.0) is True\n+\n+\n+def test_wait_for_cleanup_after_cleanup_returns_true(monkeypatch):\n+    \"\"\"End-to-end: cleanup() starts a thread, wait_for_cleanup() joins it\n+    and reports completion. Atexit relies on this contract to ensure docker\n+    stop/rm actually finishes before the Python interpreter exits.\n+\n+    Uses ``force_remove=True`` so cleanup actually starts a worker thread —\n+    the default persist-mode cleanup is a no-op (commit 4) and never spawns\n+    a thread, so the trivial \"no thread\" branch of wait_for_cleanup is\n+    already covered by the previous test.\n+    \"\"\"\n+    monkeypatch.setattr(docker_env, \"find_docker\", lambda: \"/usr/bin/docker\")\n+    monkeypatch.setattr(docker_env, \"_get_active_profile_name\", lambda: \"default\")\n+    _mock_subprocess_run(monkeypatch)\n+    _install_fake_thread(monkeypatch)\n+\n+    env = _make_dummy_env(task_id=\"wait-test\")\n+    env.cleanup(force_remove=True)\n+    assert env.wait_for_cleanup(timeout=5.0) is True\n+\n+\n+def test_cleanup_on_env_with_no_container_id_does_not_raise(monkeypatch):\n+    \"\"\"A DockerEnvironment whose ``__init__`` failed before the container_id\n+    was set (image-pull error, docker daemon down) should still be safe to\n+    cleanup() — the post-creation failure path in callers always tries.\n+    Without this guard the daemon-down case used to NameError on the cleanup\n+    branch.\"\"\"\n+    env = docker_env.DockerEnvironment.__new__(docker_env.DockerEnvironment)\n+    env._container_id = None\n+    env._persistent = False\n+    env._workspace_dir = None\n+    env._home_dir = None\n+    # No exception expected.\n+    env.cleanup()\n+\n+\n+# ── Orphan reaper (issue #20561) ──────────────────────────────────\n+\n+\n+def _now_iso(offset_seconds: int = 0) -> str:\n+    \"\"\"Return an RFC3339 timestamp ``offset_seconds`` in the past.\"\"\"\n+    import datetime\n+    t = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=offset_seconds)\n+    # Format like Docker emits — with nanoseconds-style trailing digits.\n+    return t.isoformat().replace(\"+00:00\", \".123456789Z\")\n+\n+\n+def _reaper_run_mock(monkeypatch, ps_ids: list[str], inspect_responses: dict[str, str],\n+                      rm_succeeds: bool = True):\n+    \"\"\"Build a subprocess.run mock for reaper tests.\n+\n+    * ``ps_ids`` — what ``docker ps -a --filter ... --format '{{.ID}}'`` returns\n+    * ``inspect_responses[cid]`` — what ``docker inspect ... FinishedAt`` returns\n+      for each cid; ``\"\"`` means \"field unset\".\n+    * ``rm_succeeds`` — whether ``docker rm -f`` returns 0.\n+\n+    Captures every call so tests can assert which containers were rm'd.\n+    \"\"\"\n+    calls = []\n+\n+    def _run(cmd, **kwargs):\n+        calls.append((list(cmd) if isinstance(cmd, list) else cmd, kwargs))\n+        if not isinstance(cmd, list) or len(cmd) < 2:\n+            return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+        sub = cmd[1]\n+        if sub == \"ps\":\n+            return subprocess.CompletedProcess(\n+                cmd, 0, stdout=\"\\n\".join(ps_ids) + (\"\\n\" if ps_ids else \"\"), stderr=\"\",\n+            )\n+        if sub == \"inspect\":\n+            # cmd is [docker, inspect, --format, '{{.State.FinishedAt}}', cid]\n+            cid = cmd[-1]\n+            return subprocess.CompletedProcess(\n+                cmd, 0, stdout=inspect_responses.get(cid, \"\") + \"\\n\", stderr=\"\",\n+            )\n+        if sub == \"rm\":\n+            return subprocess.CompletedProcess(\n+                cmd, 0 if rm_succeeds else 1,\n+                stdout=\"\", stderr=\"\" if rm_succeeds else \"no such container\",\n+            )\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+    return calls\n+\n+\n+def test_reap_orphan_returns_zero_when_no_matches(monkeypatch):\n+    \"\"\"No labeled containers → no rm calls, returns 0. Establishes the\n+    happy-path baseline for the orphan reaper (issue #20561).\"\"\"\n+    calls = _reaper_run_mock(monkeypatch, ps_ids=[], inspect_responses={})\n+\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    assert removed == 0\n+    rms = [c for c in calls if isinstance(c[0], list) and c[0][1:2] == [\"rm\"]]\n+    assert not rms, \"no rm calls expected when ps returns empty\"\n+\n+\n+def test_reap_orphan_removes_stale_exited_container(monkeypatch):\n+    \"\"\"An Exited container older than max_age_seconds must be removed.\n+    This is the core repair path for issue #20561 — without the reaper,\n+    SIGKILL'd Hermes processes leak containers permanently.\"\"\"\n+    old = _now_iso(offset_seconds=900)  # 15 minutes ago\n+    calls = _reaper_run_mock(\n+        monkeypatch, ps_ids=[\"old-cid\"], inspect_responses={\"old-cid\": old},\n+    )\n+\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    assert removed == 1\n+    rms = [c for c in calls if isinstance(c[0], list) and c[0][1:2] == [\"rm\"]]\n+    assert len(rms) == 1\n+    assert \"old-cid\" in rms[0][0], f\"expected rm of old-cid, got {rms[0][0]}\"\n+\n+\n+def test_reap_orphan_spares_recently_exited_container(monkeypatch):\n+    \"\"\"A container exited within max_age_seconds must NOT be reaped — that\n+    container belongs to a Hermes process that just finished and may be\n+    about to be replaced. Conservative window prevents racing sibling\n+    processes.\"\"\"\n+    recent = _now_iso(offset_seconds=60)  # 1 minute ago\n+    calls = _reaper_run_mock(\n+        monkeypatch, ps_ids=[\"recent-cid\"], inspect_responses={\"recent-cid\": recent},\n+    )\n+\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    assert removed == 0\n+    rms = [c for c in calls if isinstance(c[0], list) and c[0][1:2] == [\"rm\"]]\n+    assert not rms, f\"recent container must not be reaped, got rm calls: {rms}\"\n+\n+\n+def test_reap_orphan_scopes_to_profile_filter_via_label(monkeypatch):\n+    \"\"\"The reaper must pass ``--filter label=hermes-profile=<profile>`` to\n+    docker ps so it never sweeps another profile's containers. A research\n+    profile must not tear down the default profile's stragglers.\"\"\"\n+    calls = _reaper_run_mock(monkeypatch, ps_ids=[], inspect_responses={})\n+\n+    docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"research-bot\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    ps_calls = [c for c in calls if isinstance(c[0], list) and c[0][1:2] == [\"ps\"]]\n+    assert ps_calls, \"expected at least one docker ps call\"\n+    flat = \" \".join(ps_calls[0][0])\n+    assert \"label=hermes-profile=research-bot\" in flat, (\n+        f\"profile filter not applied to docker ps; got args: {ps_calls[0][0]}\"\n+    )\n+    assert \"label=hermes-agent=1\" in flat, (\n+        f\"hermes-agent label filter must also be applied; got: {ps_calls[0][0]}\"\n+    )\n+    assert \"status=exited\" in flat, (\n+        \"must filter to exited containers only — running containers may \"\n+        \"belong to a sibling Hermes process and must NEVER be reaped\"\n+    )\n+\n+\n+def test_reap_orphan_skips_container_with_unparseable_finished_at(monkeypatch):\n+    \"\"\"If docker inspect returns the zero-value ``0001-01-01T00:00:00Z`` (no\n+    FinishedAt yet) or an unparseable timestamp, the reaper must leave the\n+    container alone. Defensive — never reap a container whose age we can't\n+    determine.\"\"\"\n+    calls = _reaper_run_mock(\n+        monkeypatch,\n+        ps_ids=[\"never-finished\", \"garbage-ts\"],\n+        inspect_responses={\n+            \"never-finished\": \"0001-01-01T00:00:00Z\",\n+            \"garbage-ts\": \"not-a-timestamp\",\n+        },\n+    )\n+\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    assert removed == 0\n+    rms = [c for c in calls if isinstance(c[0], list) and c[0][1:2] == [\"rm\"]]\n+    assert not rms, (\n+        f\"reaper must NOT remove containers with unparseable FinishedAt; got: {rms}\"\n+    )\n+\n+\n+def test_reap_orphan_handles_docker_ps_failure_gracefully(monkeypatch):\n+    \"\"\"If docker ps itself fails (daemon down, permission denied), the\n+    reaper returns 0 without crashing. The reaper is best-effort plumbing,\n+    not a critical path — it must never block container creation.\"\"\"\n+    def _failing_ps(cmd, **kwargs):\n+        if isinstance(cmd, list) and len(cmd) >= 2 and cmd[1] == \"ps\":\n+            return subprocess.CompletedProcess(cmd, 1, stdout=\"\", stderr=\"Cannot connect to daemon\")\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _failing_ps)\n+\n+    # Must not raise\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+    assert removed == 0\n+\n+\n+def test_reap_orphan_continues_after_individual_rm_failure(monkeypatch):\n+    \"\"\"If ``docker rm -f`` fails on one container (already removed by a\n+    concurrent process, container locked, etc.), the reaper must log and\n+    continue to the next candidate rather than aborting the whole sweep.\"\"\"\n+    old = _now_iso(offset_seconds=900)\n+    rm_calls = []\n+\n+    def _run(cmd, **kwargs):\n+        if not isinstance(cmd, list) or len(cmd) < 2:\n+            return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+        sub = cmd[1]\n+        if sub == \"ps\":\n+            return subprocess.CompletedProcess(\n+                cmd, 0, stdout=\"cid-a\\ncid-b\\ncid-c\\n\", stderr=\"\",\n+            )\n+        if sub == \"inspect\":\n+            return subprocess.CompletedProcess(cmd, 0, stdout=old + \"\\n\", stderr=\"\")\n+        if sub == \"rm\":\n+            rm_calls.append(cmd[-1])\n+            # cid-b fails; cid-a and cid-c succeed.\n+            if cmd[-1] == \"cid-b\":\n+                return subprocess.CompletedProcess(cmd, 1, stdout=\"\", stderr=\"no such container\")\n+            return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+        return subprocess.CompletedProcess(cmd, 0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+\n+    removed = docker_env.reap_orphan_containers(\n+        max_age_seconds=600, profile_filter=\"default\", docker_exe=\"/usr/bin/docker\",\n+    )\n+\n+    # All three were attempted, two succeeded.\n+    assert removed == 2\n+    assert set(rm_calls) == {\"cid-a\", \"cid-b\", \"cid-c\"}, (\n+        f\"reaper must attempt all candidates even when one fails; got: {rm_calls}\"\n+    )\n+\n+\n+def test_container_finished_at_parses_nanosecond_timestamp(monkeypatch):\n+    \"\"\"Docker emits FinishedAt with nanosecond precision (RFC3339 with up to\n+    9 fractional digits), but Python's fromisoformat caps at microseconds.\n+    The helper must trim the extra digits without raising — otherwise every\n+    candidate gets skipped and the reaper does nothing.\"\"\"\n+\n+    def _run(cmd, **kwargs):\n+        return subprocess.CompletedProcess(\n+            cmd, 0,\n+            stdout=\"2026-05-28T13:45:00.123456789Z\\n\",\n+            stderr=\"\",\n+        )\n+\n+    monkeypatch.setattr(docker_env.subprocess, \"run\", _run)\n+\n+    result = docker_env._container_finished_at(\"/usr/bin/docker\", \"test-cid\")\n+    assert result is not None, \"must parse RFC3339 with nanoseconds\"\n+    import datetime\n+    assert result.tzinfo == datetime.timezone.utc\n+    assert result.year == 2026 and result.month == 5 and result.day == 28\n+\n+\n+def test_container_finished_at_returns_none_on_zero_value():\n+    \"\"\"Docker's zero-value ``0001-01-01T00:00:00Z`` (never finished) must\n+    map to None so the reaper treats the container as unreapable.\"\"\"\n+    # Direct test of the parsing helper — no subprocess needed since the\n+    # check happens after the inspect call returns.\n+    import subprocess as _subprocess\n+\n+    class _MockRun:\n+        def __init__(self, stdout):\n+            self.returncode = 0\n+            self.stdout = stdout\n+            self.stderr = \"\"\n+\n+    import unittest.mock\n+    with unittest.mock.patch.object(\n+        docker_env.subprocess, \"run\", return_value=_MockRun(\"0001-01-01T00:00:00Z\\n\"),\n+    ):\n+        result = docker_env._container_finished_at(\"/usr/bin/docker\", \"never-finished\")\n+    assert result is None"]},{"file":"tests/tools/test_docker_orphan_reaper_integration.py","hunks":["@@ -0,0 +1,139 @@\n+\"\"\"Integration tests for the docker orphan-reaper wiring in terminal_tool.\n+\n+The reaper itself is unit-tested in tests/tools/test_docker_environment.py\n+under the \"Orphan reaper\" section. These tests cover the terminal_tool-side\n+gates: once-per-process behavior, the disable flag, and the\n+``lifetime_seconds`` doubling that determines the reaper's age threshold.\n+\n+Issue #20561 — without these gates, parallel subagents would each fire the\n+reaper on container creation, and the ``terminal.docker_orphan_reaper: false``\n+opt-out would silently do nothing.\n+\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+import tools.terminal_tool as terminal_tool\n+\n+\n+def _reset_reaper_gate():\n+    \"\"\"Clear the once-per-process flag between tests.\"\"\"\n+    terminal_tool._docker_orphan_reaper_ran = False\n+\n+\n+def test_maybe_reap_runs_once_per_process(monkeypatch):\n+    \"\"\"The reaper sweep must run at most once per Python interpreter.\n+    Parallel subagents that each call _create_environment(env_type='docker')\n+    would otherwise fire N concurrent docker ps + inspect storms against the\n+    daemon and waste 5–10s of startup.\"\"\"\n+    _reset_reaper_gate()\n+    call_count = {\"reap\": 0}\n+\n+    def _fake_reap(**kwargs):\n+        call_count[\"reap\"] += 1\n+        return 0\n+\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _fake_reap):\n+        config = {\"docker_orphan_reaper\": True}\n+        terminal_tool._maybe_reap_docker_orphans(config)\n+        terminal_tool._maybe_reap_docker_orphans(config)\n+        terminal_tool._maybe_reap_docker_orphans(config)\n+\n+    assert call_count[\"reap\"] == 1, (\n+        f\"reaper must run exactly once per process; got {call_count['reap']} calls\"\n+    )\n+\n+\n+def test_maybe_reap_respects_disable_flag(monkeypatch):\n+    \"\"\"``terminal.docker_orphan_reaper: false`` (via container_config) must\n+    skip the sweep entirely — no docker ps, no inspect, no rm. The escape\n+    hatch for operators running multiple Hermes processes in the same\n+    profile.\"\"\"\n+    _reset_reaper_gate()\n+    call_count = {\"reap\": 0}\n+\n+    def _fake_reap(**kwargs):\n+        call_count[\"reap\"] += 1\n+        return 0\n+\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _fake_reap):\n+        terminal_tool._maybe_reap_docker_orphans({\"docker_orphan_reaper\": False})\n+\n+    assert call_count[\"reap\"] == 0, \"disabled reaper must not run any docker calls\"\n+    # The once-per-process gate must NOT be tripped when the reaper is\n+    # disabled — that would prevent a subsequent toggle to true from working.\n+    assert terminal_tool._docker_orphan_reaper_ran is False\n+\n+\n+def test_maybe_reap_doubles_lifetime_for_max_age(monkeypatch):\n+    \"\"\"The reaper's age threshold is ``2 × lifetime_seconds`` (with a 60s\n+    floor). Generous default — gives sibling Hermes processes ample grace\n+    to be replaced without their just-exited containers being yanked.\"\"\"\n+    _reset_reaper_gate()\n+    captured_args = {}\n+\n+    def _fake_reap(**kwargs):\n+        captured_args.update(kwargs)\n+        return 0\n+\n+    monkeypatch.setenv(\"TERMINAL_LIFETIME_SECONDS\", \"300\")\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _fake_reap):\n+        terminal_tool._maybe_reap_docker_orphans({\"docker_orphan_reaper\": True})\n+\n+    assert captured_args.get(\"max_age_seconds\") == 600, (\n+        f\"expected 2 × 300 = 600, got {captured_args.get('max_age_seconds')}\"\n+    )\n+\n+\n+def test_maybe_reap_floors_at_60_seconds(monkeypatch):\n+    \"\"\"A user pinning TERMINAL_LIFETIME_SECONDS=0 (or any value <30) would\n+    otherwise get an effective age threshold of zero, which would race the\n+    user's own just-started container creation. Floor at 60s × 2 = 120s.\"\"\"\n+    _reset_reaper_gate()\n+    captured_args = {}\n+\n+    def _fake_reap(**kwargs):\n+        captured_args.update(kwargs)\n+        return 0\n+\n+    monkeypatch.setenv(\"TERMINAL_LIFETIME_SECONDS\", \"0\")\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _fake_reap):\n+        terminal_tool._maybe_reap_docker_orphans({\"docker_orphan_reaper\": True})\n+\n+    assert captured_args.get(\"max_age_seconds\") == 120, (\n+        f\"expected floored 60 × 2 = 120, got {captured_args.get('max_age_seconds')}\"\n+    )\n+\n+\n+def test_maybe_reap_passes_current_profile_as_filter(monkeypatch):\n+    \"\"\"The reaper must be scoped to the current Hermes profile — a research\n+    profile must NEVER reap default's containers. Verifies the\n+    profile-filter wiring.\"\"\"\n+    _reset_reaper_gate()\n+    captured_args = {}\n+\n+    def _fake_reap(**kwargs):\n+        captured_args.update(kwargs)\n+        return 0\n+\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _fake_reap), \\\n+         patch(\"tools.environments.docker._get_active_profile_name\", return_value=\"research-bot\"):\n+        terminal_tool._maybe_reap_docker_orphans({\"docker_orphan_reaper\": True})\n+\n+    assert captured_args.get(\"profile_filter\") == \"research-bot\", (\n+        f\"expected profile_filter='research-bot', got {captured_args.get('profile_filter')!r}\"\n+    )\n+\n+\n+def test_maybe_reap_swallows_exceptions(monkeypatch):\n+    \"\"\"A reaper crash (docker daemon down, parse error in helper) must NOT\n+    block env creation. The reaper is best-effort plumbing, not a critical\n+    path; failures get logged at debug level and execution continues.\"\"\"\n+    _reset_reaper_gate()\n+\n+    def _exploding_reap(**kwargs):\n+        raise RuntimeError(\"docker daemon ate the cat\")\n+\n+    with patch(\"tools.environments.docker.reap_orphan_containers\", _exploding_reap):\n+        # Must not raise\n+        terminal_tool._maybe_reap_docker_orphans({\"docker_orphan_reaper\": True})"]},{"file":"tests/tools/test_terminal_config_env_sync.py","hunks":["@@ -224,3 +224,39 @@ def test_docker_env_is_bridged_everywhere():\n     assert \"docker_env\" in _gateway_env_map_keys()\n     assert \"docker_env\" in _save_config_env_sync_keys()\n     assert \"TERMINAL_DOCKER_ENV\" in _terminal_tool_env_var_names()\n+\n+\n+def test_docker_persist_across_processes_is_bridged_everywhere():\n+    \"\"\"Regression pin for the cross-process container reuse toggle.\n+\n+    ``terminal.docker_persist_across_processes`` (issue #20561) controls\n+    whether ``DockerEnvironment.__init__`` probes for and reuses an existing\n+    labeled container at startup, and whether ``cleanup()`` removes the\n+    container on Hermes exit or just stops it (keeping it for the next\n+    process).  Same four-bridge invariant as docker_run_as_host_user /\n+    docker_env / docker_mount_cwd_to_workspace — drift between any of the\n+    four sites means ``terminal.docker_persist_across_processes: false`` in\n+    config.yaml silently does nothing for that entry point, leaving the\n+    user unable to opt out of the documented \"ONE long-lived container\n+    shared across sessions\" behavior.\n+    \"\"\"\n+    assert \"docker_persist_across_processes\" in _cli_env_map_keys()\n+    assert \"docker_persist_across_processes\" in _gateway_env_map_keys()\n+    assert \"docker_persist_across_processes\" in _save_config_env_sync_keys()\n+    assert \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\" in _terminal_tool_env_var_names()\n+\n+\n+def test_docker_orphan_reaper_is_bridged_everywhere():\n+    \"\"\"Regression pin for the startup orphan reaper toggle (issue #20561).\n+\n+    ``terminal.docker_orphan_reaper`` controls whether Hermes sweeps stale\n+    Exited containers from prior SIGKILL'd processes at startup.  Same\n+    four-site bridge invariant — drift means\n+    ``terminal.docker_orphan_reaper: false`` silently does nothing for one\n+    entry point, and the reaper either runs when the operator disabled it\n+    or fails to run when they enabled it.\n+    \"\"\"\n+    assert \"docker_orphan_reaper\" in _cli_env_map_keys()\n+    assert \"docker_orphan_reaper\" in _gateway_env_map_keys()\n+    assert \"docker_orphan_reaper\" in _save_config_env_sync_keys()\n+    assert \"TERMINAL_DOCKER_ORPHAN_REAPER\" in _terminal_tool_env_var_names()"]},{"file":"tools/environments/docker.py","hunks":["@@ -98,6 +98,167 @@ def _load_hermes_env_vars() -> dict[str, str]:\n         return {}\n \n \n+# Docker label values must match [a-zA-Z0-9_.-] and stay ≤63 chars to round-trip\n+# safely through `docker ps --filter label=key=value`. Profile and task names\n+# can technically contain other characters; sanitize defensively.\n+_LABEL_VALUE_OK_RE = re.compile(r\"[^A-Za-z0-9_.-]\")\n+\n+\n+def _sanitize_label_value(value: str) -> str:\n+    \"\"\"Coerce *value* into a Docker label-safe form (alnum + ``_.-``, ≤63 chars).\n+\n+    Empty or all-invalid inputs collapse to ``\"unknown\"`` so the resulting\n+    label is always queryable. Used at container-create time; never round-trip\n+    a sanitized value back into application logic.\n+    \"\"\"\n+    if not isinstance(value, str) or not value:\n+        return \"unknown\"\n+    cleaned = _LABEL_VALUE_OK_RE.sub(\"_\", value)\n+    cleaned = cleaned[:63] or \"unknown\"\n+    return cleaned\n+\n+\n+def _get_active_profile_name() -> str:\n+    \"\"\"Return the active Hermes profile name, or ``\"default\"`` on any error.\n+\n+    Resolved at container-create time so a single container is permanently\n+    tagged with the profile that created it. Profile switches inside the\n+    same process don't retroactively relabel running containers.\n+    \"\"\"\n+    try:\n+        from hermes_cli.profiles import get_active_profile_name\n+\n+        return get_active_profile_name() or \"default\"\n+    except Exception:\n+        return \"default\"\n+\n+\n+def reap_orphan_containers(\n+    *,\n+    max_age_seconds: int = 600,\n+    profile_filter: str | None = None,\n+    docker_exe: str | None = None,\n+) -> int:\n+    \"\"\"Remove stale hermes-tagged containers left behind by prior processes.\n+\n+    Targets containers that match all of:\n+\n+    * ``label=hermes-agent=1`` (created by this codebase)\n+    * ``status=exited`` (running containers are NEVER reaped — they may\n+      belong to a sibling Hermes process whose reuse path will pick them\n+      up; killing them would crash the sibling mid-command)\n+    * (optional) ``label=hermes-profile=<profile_filter>`` (sweep only the\n+      caller's profile by default; a hermes process in profile A must not\n+      tear down profile B's containers)\n+    * ``State.FinishedAt`` older than *max_age_seconds* ago (so a sibling\n+      process that just exited and is about to be replaced doesn't get\n+      its container yanked out from under it)\n+\n+    Returns the number of containers removed. Best-effort: any failure\n+    (docker daemon unreachable, slow inspect, parse error) is logged at\n+    debug level and the function returns whatever it managed before the\n+    failure. Safe to call repeatedly; idempotent.\n+\n+    Issue #20561 — this is the safety net for SIGKILL / OOM / crashed\n+    terminal exits that bypass the ``atexit`` cleanup hook. Without it,\n+    even with the cleanup-fix in the prior commit, a hard-killed Hermes\n+    process leaves its container behind permanently because there's no\n+    subsequent Hermes process scheduled to reuse that exact (task, profile)\n+    pair.\n+    \"\"\"\n+    docker = docker_exe or find_docker() or \"docker\"\n+    filters = [\"--filter\", \"label=hermes-agent=1\", \"--filter\", \"status=exited\"]\n+    if profile_filter:\n+        filters.extend([\"--filter\", f\"label=hermes-profile={_sanitize_label_value(profile_filter)}\"])\n+\n+    try:\n+        listing = subprocess.run(\n+            [docker, \"ps\", \"-a\", *filters, \"--format\", \"{{.ID}}\"],\n+            capture_output=True, text=True, timeout=15, check=False,\n+        )\n+    except (subprocess.TimeoutExpired, OSError) as e:\n+        logger.debug(\"orphan reaper docker ps failed: %s\", e)\n+        return 0\n+    if listing.returncode != 0:\n+        logger.debug(\n+            \"orphan reaper docker ps returned %d: %s\",\n+            listing.returncode, listing.stderr.strip(),\n+        )\n+        return 0\n+\n+    candidate_ids = [ln.strip() for ln in listing.stdout.splitlines() if ln.strip()]\n+    if not candidate_ids:\n+        return 0\n+\n+    # Inspect each candidate to get FinishedAt; reap only those exited\n+    # long enough ago.  Doing this per-container (rather than bulk inspect)\n+    # keeps the failure blast radius to one container at a time.\n+    import datetime\n+    now = datetime.datetime.now(datetime.timezone.utc)\n+    removed = 0\n+    for cid in candidate_ids:\n+        finished_at = _container_finished_at(docker, cid)\n+        if finished_at is None:\n+            # Couldn't determine age — be conservative and leave it alone.\n+            continue\n+        age = (now - finished_at).total_seconds()\n+        if age < max_age_seconds:\n+            continue\n+        try:\n+            result = subprocess.run(\n+                [docker, \"rm\", \"-f\", cid],\n+                capture_output=True, text=True, timeout=30,\n+            )\n+            if result.returncode == 0:\n+                removed += 1\n+                logger.info(\n+                    \"Reaped orphan container %s (exited %d seconds ago)\",\n+                    cid[:12], int(age),\n+                )\n+            else:\n+                logger.debug(\n+                    \"docker rm -f %s failed: %s\",\n+                    cid[:12], result.stderr.strip(),\n+                )\n+        except (subprocess.TimeoutExpired, OSError) as e:\n+            logger.debug(\"orphan reaper docker rm %s failed: %s\", cid[:12], e)\n+    return removed\n+\n+\n+def _container_finished_at(docker_exe: str, container_id: str):\n+    \"\"\"Parse ``docker inspect`` FinishedAt for *container_id*.\n+\n+    Returns a timezone-aware datetime, or ``None`` if the field is missing,\n+    unparseable, or the zero-value ``0001-01-01T00:00:00Z`` Docker emits\n+    for never-finished containers. ``None`` means \"don't reap\" — the caller\n+    leaves the container alone.\n+    \"\"\"\n+    try:\n+        result = subprocess.run(\n+            [docker_exe, \"inspect\", \"--format\", \"{{.State.FinishedAt}}\", container_id],\n+            capture_output=True, text=True, timeout=10, check=False,\n+        )\n+    except (subprocess.TimeoutExpired, OSError) as e:\n+        logger.debug(\"orphan reaper docker inspect %s failed: %s\", container_id[:12], e)\n+        return None\n+    if result.returncode != 0:\n+        return None\n+    raw = result.stdout.strip()\n+    if not raw or raw.startswith(\"0001-01-01\"):\n+        return None\n+    # Docker emits RFC3339 with nanoseconds (e.g. \"2026-05-28T13:45:00.123456789Z\").\n+    # Python's fromisoformat handles microseconds but not nanoseconds; trim.\n+    import re as _re\n+    raw = _re.sub(r\"(\\.\\d{6})\\d+\", r\"\\1\", raw)\n+    raw = raw.replace(\"Z\", \"+00:00\")\n+    try:\n+        import datetime\n+        return datetime.datetime.fromisoformat(raw)\n+    except ValueError as e:\n+        logger.debug(\"could not parse FinishedAt %r for %s: %s\", raw, container_id[:12], e)\n+        return None\n+\n+\n def find_docker() -> Optional[str]:\n     \"\"\"Locate the docker (or podman) CLI binary.\n ","@@ -304,15 +465,18 @@ def __init__(\n         auto_mount_cwd: bool = False,\n         run_as_host_user: bool = False,\n         extra_args: list = None,\n+        persist_across_processes: bool = True,\n     ):\n         if cwd == \"~\":\n             cwd = \"/root\"\n         super().__init__(cwd=cwd, timeout=timeout)\n         self._persistent = persistent_filesystem\n+        self._persist_across_processes = persist_across_processes\n         self._task_id = task_id\n         self._forward_env = _normalize_forward_env_names(forward_env)\n         self._env = _normalize_env_dict(env)\n         self._container_id: Optional[str] = None\n+        self._labels: dict[str, str] = {}\n         logger.info(f\"DockerEnvironment volumes: {volumes}\")\n         # Ensure volumes is a list (config.yaml could be malformed)\n         if volumes is not None and not isinstance(volumes, list):","@@ -506,25 +670,88 @@ def __init__(\n \n         # Start the container directly via `docker run -d`.\n         container_name = f\"hermes-{uuid.uuid4().hex[:8]}\"\n-        run_cmd = [\n-            self._docker_exe, \"run\", \"-d\",\n-            \"--init\",           # tini/catatonit as PID 1 — reaps zombie children\n-            \"--name\", container_name,\n-            \"-w\", cwd,\n-            *all_run_args,\n-            image,\n-            \"sleep\", \"infinity\",  # no fixed lifetime — idle reaper handles cleanup\n+        # Labels make hermes-created containers identifiable to:\n+        #   * the orphan reaper (`hermes-agent=1` for the global sweep filter)\n+        #   * future cross-process reuse (`hermes-task-id`, `hermes-profile`)\n+        #   * operators running `docker ps --filter label=hermes-agent=1`\n+        # Values are limited to the safe character set defined by\n+        # _sanitize_label_value(); the active Hermes profile is captured at\n+        # container-start time and never changes for the container's lifetime.\n+        profile_name = _sanitize_label_value(_get_active_profile_name())\n+        task_label = _sanitize_label_value(task_id)\n+        label_args = [\n+            \"--label\", \"hermes-agent=1\",\n+            \"--label\", f\"hermes-task-id={task_label}\",\n+            \"--label\", f\"hermes-profile={profile_name}\",\n         ]\n-        logger.debug(f\"Starting container: {' '.join(run_cmd)}\")\n-        result = subprocess.run(\n-            run_cmd,\n-            capture_output=True,\n-            text=True,\n-            timeout=120,  # image pull may take a while\n-            check=True,\n-        )\n-        self._container_id = result.stdout.strip()\n-        logger.info(f\"Started container {container_name} ({self._container_id[:12]})\")\n+        self._labels = {\n+            \"hermes-agent\": \"1\",\n+            \"hermes-task-id\": task_label,\n+            \"hermes-profile\": profile_name,\n+        }\n+\n+        # Cross-process container reuse (issue #20561 — docs claim \"ONE long-lived\n+        # container shared across sessions\").  If a prior Hermes process\n+        # already started a container for this (task_id, profile) and it\n+        # still exists, attach to it instead of starting a fresh one.  This\n+        # restores the documented contract; opt out via\n+        # ``terminal.docker_persist_across_processes: false``.\n+        #\n+        # Reuse matches on labels only — we deliberately do NOT compare image\n+        # / mounts / resources.  Operators who need a fresh container after\n+        # changing those settings should set ``docker_persist_across_processes:\n+        # false`` (or run ``docker rm -f`` against the labeled container) to\n+        # force a clean start.\n+        reused = False\n+        if persist_across_processes:\n+            existing = self._find_reusable_container(task_label, profile_name)\n+            if existing is not None:\n+                container_id, state = existing\n+                self._container_id = container_id\n+                if state != \"running\":\n+                    try:\n+                        subprocess.run(\n+                            [self._docker_exe, \"start\", container_id],\n+                            capture_output=True,\n+                            text=True,\n+                            timeout=30,\n+                            check=True,\n+                        )\n+                    except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:\n+                        logger.warning(\n+                            \"Failed to start existing container %s (state=%s): \"\n+                            \"%s — falling back to a fresh container.\",\n+                            container_id[:12], state, e,\n+                        )\n+                        self._container_id = None\n+                if self._container_id:\n+                    logger.info(\n+                        \"Reusing container %s (task=%s, profile=%s, prior state=%s)\",\n+                        container_id[:12], task_label, profile_name, state,\n+                    )\n+                    reused = True\n+\n+        if not reused:\n+            run_cmd = [\n+                self._docker_exe, \"run\", \"-d\",\n+                \"--init\",           # tini/catatonit as PID 1 — reaps zombie children\n+                \"--name\", container_name,\n+                *label_args,\n+                \"-w\", cwd,\n+                *all_run_args,\n+                image,\n+                \"sleep\", \"infinity\",  # no fixed lifetime — idle reaper handles cleanup\n+            ]\n+            logger.debug(f\"Starting container: {' '.join(run_cmd)}\")\n+            result = subprocess.run(\n+                run_cmd,\n+                capture_output=True,\n+                text=True,\n+                timeout=120,  # image pull may take a while\n+                check=True,\n+            )\n+            self._container_id = result.stdout.strip()\n+            logger.info(f\"Started container {container_name} ({self._container_id[:12]})\")\n \n         # Build the init-time env forwarding args (used only by init_session\n         # to inject host env vars into the snapshot; subsequent commands get","@@ -629,31 +856,191 @@ def _storage_opt_supported() -> bool:\n         logger.debug(\"Docker --storage-opt support: %s\", _storage_opt_ok)\n         return _storage_opt_ok\n \n-    def cleanup(self):\n-        \"\"\"Stop and remove the container. Bind-mount dirs persist if persistent=True.\"\"\"\n-        if self._container_id:\n-            try:\n-                # Stop in background so cleanup doesn't block\n-                stop_cmd = (\n-                    f\"(timeout 60 {self._docker_exe} stop {self._container_id} || \"\n-                    f\"{self._docker_exe} rm -f {self._container_id}) >/dev/null 2>&1 &\"\n-                )\n-                subprocess.Popen(stop_cmd, shell=True)\n-            except Exception as e:\n-                logger.warning(\"Failed to stop container %s: %s\", self._container_id, e)\n+    def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]:\n+        \"\"\"Look for an existing container labeled for this (task, profile).\n \n+        Returns ``(container_id, state)`` on hit, ``None`` on miss / on any\n+        failure (including ``docker ps`` itself failing). State is one of the\n+        values Docker reports via ``{{.State}}`` — e.g. ``running``, ``exited``,\n+        ``created``, ``paused``, ``restarting``, ``dead``. The caller decides\n+        whether the state warrants ``docker start`` before reuse.\n+\n+        Restricted to the docker-stored label set this class creates; never\n+        matches containers that happened to be named ``hermes-*`` but were\n+        started by some other tool.\n+        \"\"\"\n+        try:\n+            result = subprocess.run(\n+                [\n+                    self._docker_exe, \"ps\", \"-a\",\n+                    \"--filter\", \"label=hermes-agent=1\",\n+                    \"--filter\", f\"label=hermes-task-id={task_label}\",\n+                    \"--filter\", f\"label=hermes-profile={profile_label}\",\n+                    \"--format\", \"{{.ID}}\\t{{.State}}\",\n+                ],\n+                capture_output=True,\n+                text=True,\n+                timeout=10,\n+                check=False,\n+            )\n+        except (subprocess.TimeoutExpired, OSError) as e:\n+            logger.debug(\"docker ps probe failed: %s — will start a fresh container\", e)\n+            return None\n+        if result.returncode != 0:\n+            logger.debug(\n+                \"docker ps probe returned %d: %s — will start a fresh container\",\n+                result.returncode, result.stderr.strip(),\n+            )\n+            return None\n+        lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]\n+        if not lines:\n+            return None\n+        # Multiple matches are unusual (one (task, profile) should produce one\n+        # container) but can happen if a previous Hermes process crashed\n+        # mid-cleanup. Prefer a running one if present; otherwise pick the\n+        # first listed. Stale duplicates get reaped by the orphan-reaper in a\n+        # follow-up commit; we don't try to be heroic about them here.\n+        running = None\n+        first = None\n+        for ln in lines:\n+            parts = ln.split(\"\\t\", 1)\n+            if len(parts) != 2:\n+                continue\n+            cid, state = parts[0], parts[1].lower()\n+            if first is None:\n+                first = (cid, state)\n+            if state == \"running\" and running is None:\n+                running = (cid, state)\n+        return running or first\n+\n+    def cleanup(self, *, force_remove: bool = False):\n+        \"\"\"Tear down the container according to persist mode and *force_remove*.\n+\n+        Persist-mode (``persist_across_processes=True``, the default) leaves the\n+        container **running** untouched. The docs promise \"ONE long-lived\n+        container shared across sessions\" and stopping it on every Hermes exit\n+        breaks that promise:\n+\n+        * Background processes inside the container (``npm run dev``, watchers,\n+          long-running pytest) get killed every time the user runs ``/quit``.\n+        * Every reuse requires ``docker start`` + waiting for the container to\n+          come back up, adding 1–2s to the first tool call of the new session.\n+        * The user-visible difference between \"ONE long-lived container\" and\n+          \"a new container that happens to share state\" is exactly this:\n+          processes survive in the former, die in the latter.\n+\n+        Resource reclamation for the persist-mode case lives in the\n+        ``reap_orphan_containers()`` path (see issue #20561 commit 3): if no\n+        Hermes process touches a labeled container for ``2 × lifetime_seconds``\n+        it gets ``docker rm -f``'d at the next Hermes startup. That covers the\n+        SIGKILL / OOM / abandoned-laptop cases without us needing to stop the\n+        container on every graceful exit.\n+\n+        Opt-out mode (``persist_across_processes=False``) still does\n+        ``docker stop`` + ``docker rm -f`` on every cleanup, matching the\n+        pre-PR behavior for users who explicitly want per-process isolation.\n+\n+        ``force_remove=True`` overrides persist mode and always tears the\n+        container down (``docker stop`` + ``docker rm -f``). This is the\n+        explicit-teardown path for ``/reset``, ``cleanup_vm(task_id)``-driven\n+        resets, or any caller that wants a guaranteed fresh container on next\n+        ``DockerEnvironment(task_id=...)``. No current caller passes\n+        ``force_remove=True``; the parameter is here so the explicit-teardown\n+        semantics can be wired up later without changing this method's\n+        signature.\n+\n+        Cleanup runs on a daemon thread with bounded ``subprocess.run`` calls\n+        (not the racy ``Popen(... &)`` pattern from before PR #33645). The\n+        atexit hook in ``tools/terminal_tool.py`` waits up to 15s for the\n+        thread to finish before the interpreter exits, so ``docker stop`` /\n+        ``docker rm`` actually completes when we do trigger it.\n+        \"\"\"\n+        container_id = self._container_id\n+        if not container_id:\n+            # Still drop the bind-mount dirs if any were allocated and we're\n+            # NOT in persist mode (persist mode preserves them).\n             if not self._persistent:\n-                # Also schedule removal (stop only leaves it as stopped)\n-                try:\n-                    subprocess.Popen(\n-                        f\"sleep 3 && {self._docker_exe} rm -f {self._container_id} >/dev/null 2>&1 &\",\n-                        shell=True,\n-                    )\n-                except Exception:\n-                    pass\n+                for d in (self._workspace_dir, self._home_dir):\n+                    if d:\n+                        shutil.rmtree(d, ignore_errors=True)\n+            return\n+\n+        # Decide what to actually do. Three cases:\n+        #\n+        #   force_remove=True             → stop + rm (explicit teardown)\n+        #   persist_across_processes=True → no-op (leave container running)\n+        #   persist_across_processes=False → stop + rm (per-process isolation)\n+        #\n+        # The persist-mode no-op is the issue-#20561 contract: the container\n+        # outlives Hermes processes, processes inside it stay alive, and\n+        # reuse on next startup is instant.\n+        if force_remove:\n+            should_stop = True\n+            should_remove = True\n+        elif self._persist_across_processes:\n+            # No-op for the container. Drop the in-process handle so a fresh\n+            # __init__ will re-probe via labels (and find the running\n+            # container) instead of trying to reuse a stale Python reference.\n             self._container_id = None\n+            return\n+        else:\n+            should_stop = True\n+            should_remove = True\n \n-        if not self._persistent:\n+        # Capture state needed by the worker before we null out the attrs —\n+        # the worker thread can outlive ``self``.\n+        docker_exe = self._docker_exe\n+        log_id = container_id[:12]\n+\n+        def _do_cleanup() -> None:\n+            if should_stop:\n+                try:\n+                    subprocess.run(\n+                        [docker_exe, \"stop\", \"-t\", \"10\", container_id],\n+                        capture_output=True, timeout=30,\n+                    )\n+                except (subprocess.TimeoutExpired, OSError) as e:\n+                    logger.warning(\"docker stop %s timed out / failed: %s\", log_id, e)\n+            if should_remove:\n+                try:\n+                    subprocess.run(\n+                        [docker_exe, \"rm\", \"-f\", container_id],\n+                        capture_output=True, timeout=30,\n+                    )\n+                except (subprocess.TimeoutExpired, OSError) as e:\n+                    logger.warning(\"docker rm -f %s failed: %s\", log_id, e)\n+\n+        # Daemon thread: doesn't block interpreter exit (atexit returns\n+        # promptly), but unlike the old ``Popen(... &)`` shell trick the\n+        # Python-level join semantics let the thread actually run to\n+        # completion if the interpreter is still alive. atexit registers\n+        # ``_atexit_cleanup`` in terminal_tool.py which waits up to ~60s for\n+        # outstanding cleanups, so most exits complete the work cleanly.\n+        import threading\n+        t = threading.Thread(target=_do_cleanup, daemon=True, name=f\"hermes-cleanup-{log_id}\")\n+        t.start()\n+        self._cleanup_thread = t\n+        self._container_id = None\n+\n+        # Bind-mount dir teardown only runs when we actually removed the\n+        # container (the dirs are the container's filesystem state; keeping\n+        # them around with no container would orphan the data on disk).\n+        if should_remove and not self._persistent:\n             for d in (self._workspace_dir, self._home_dir):\n                 if d:\n                     shutil.rmtree(d, ignore_errors=True)\n+\n+    def wait_for_cleanup(self, timeout: float = 30.0) -> bool:\n+        \"\"\"Block up to *timeout* seconds for the cleanup worker thread.\n+\n+        Returns ``True`` if the thread finished (or no thread was started),\n+        ``False`` on timeout. The atexit hook in terminal_tool.py calls this\n+        on every active environment so docker stop/rm actually completes\n+        before the Python process exits — without this, ``hermes /quit``\n+        races the interpreter shutdown and leaves stopped containers behind.\n+        \"\"\"\n+        thread = getattr(self, \"_cleanup_thread\", None)\n+        if thread is None or not thread.is_alive():\n+            return True\n+        thread.join(timeout=timeout)\n+        return not thread.is_alive()"]},{"file":"tools/terminal_tool.py","hunks":["@@ -860,6 +860,78 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None\n _cleanup_thread = None\n _cleanup_running = False\n \n+# Once-per-process guard for the docker orphan reaper (issue #20561).\n+# Set when _maybe_reap_docker_orphans first runs; concurrent _create_environment\n+# calls for parallel subagents won't re-trigger the sweep.\n+_docker_orphan_reaper_ran = False\n+_docker_orphan_reaper_lock = threading.Lock()\n+\n+\n+def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:\n+    \"\"\"Run the docker orphan reaper once per process, if enabled.\n+\n+    Sweeps long-Exited containers labeled ``hermes-agent=1`` for the current\n+    profile that match the issue #20561 leak class — containers left behind\n+    by Hermes processes that exited without firing ``atexit`` (SIGKILL,\n+    OOM, terminal-window-close). The reaper is conservative by default:\n+    only Exited containers older than ``2 × lifetime_seconds`` and scoped to\n+    the current profile.\n+\n+    Gates:\n+\n+    * ``terminal.docker_orphan_reaper: false`` disables it entirely (the\n+      operator opted out — usually because they're running multiple\n+      Hermes processes in the same profile and don't trust the\n+      conservative defaults).\n+    * ``_docker_orphan_reaper_ran`` flag — sweep runs once per Python\n+      interpreter, not on every subagent / RL-rollout / parallel\n+      ``terminal()`` call.\n+    \"\"\"\n+    global _docker_orphan_reaper_ran\n+    if not container_config.get(\"docker_orphan_reaper\", True):\n+        return\n+    # Cheap double-checked-locking: read without the lock, take the lock\n+    # only on first run, recheck inside.\n+    if _docker_orphan_reaper_ran:\n+        return\n+    with _docker_orphan_reaper_lock:\n+        if _docker_orphan_reaper_ran:\n+            return\n+        _docker_orphan_reaper_ran = True\n+\n+    # 2 × lifetime_seconds gives sibling Hermes processes a generous grace\n+    # window. Floor at 60s so an operator with TERMINAL_LIFETIME_SECONDS=0\n+    # doesn't get an instant-reap that races their own setup.\n+    # ``container_config`` only carries container_* keys, so read\n+    # lifetime_seconds from the env var the rest of the module uses.\n+    try:\n+        lifetime = int(os.getenv(\"TERMINAL_LIFETIME_SECONDS\", \"300\"))\n+    except (TypeError, ValueError):\n+        lifetime = 300\n+    lifetime = max(60, lifetime)\n+    max_age = lifetime * 2\n+\n+    try:\n+        from tools.environments.docker import (\n+            reap_orphan_containers, _get_active_profile_name,\n+        )\n+    except ImportError:\n+        return\n+    try:\n+        profile = _get_active_profile_name()\n+        removed = reap_orphan_containers(\n+            max_age_seconds=max_age, profile_filter=profile,\n+        )\n+        if removed:\n+            logger.info(\n+                \"Docker orphan reaper removed %d stale container(s) for profile %s\",\n+                removed, profile,\n+            )\n+    except Exception as e:\n+        # Never fail the env-creation path because of a janitor problem.\n+        logger.debug(\"Docker orphan reaper raised: %s\", e)\n+\n+\n # Per-task environment overrides registry.\n # Allows environments (e.g., TerminalBench2Env) to specify a custom Docker/Modal\n # image for a specific task_id BEFORE the agent loop starts. When the terminal or","@@ -1023,6 +1095,22 @@ def _get_env_config() -> Dict[str, Any]:\n         \"docker_env\": _parse_env_var(\"TERMINAL_DOCKER_ENV\", \"{}\", json.loads, \"valid JSON\"),\n         \"docker_run_as_host_user\": os.getenv(\"TERMINAL_DOCKER_RUN_AS_HOST_USER\", \"false\").lower() in {\"true\", \"1\", \"yes\"},\n         \"docker_extra_args\": _parse_env_var(\"TERMINAL_DOCKER_EXTRA_ARGS\", \"[]\", json.loads, \"valid JSON\"),\n+        # Cross-process container reuse (issue #20561).  The docs claim\n+        # \"ONE long-lived container shared across sessions\" — this toggle\n+        # makes that real by probing for a labeled container at startup and\n+        # attaching to it instead of always starting a fresh one.  Set to\n+        # ``false`` for hard per-process isolation (no reuse, container is\n+        # removed on exit).\n+        \"docker_persist_across_processes\": os.getenv(\n+            \"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES\", \"true\"\n+        ).lower() in {\"true\", \"1\", \"yes\"},\n+        # Startup orphan reaper for hermes-tagged containers left behind by\n+        # crashed / SIGKILL'd previous processes that bypassed atexit.\n+        # Conservative: only sweeps Exited containers older than 2× the\n+        # idle-reap window AND scoped to the current profile. Issue #20561.\n+        \"docker_orphan_reaper\": os.getenv(\n+            \"TERMINAL_DOCKER_ORPHAN_REAPER\", \"true\"\n+        ).lower() in {\"true\", \"1\", \"yes\"},\n     }\n \n ","@@ -1071,6 +1159,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,\n         return _LocalEnvironment(cwd=cwd, timeout=timeout)\n     \n     elif env_type == \"docker\":\n+        # One-shot orphan reaper: clean up labeled containers left behind by\n+        # prior Hermes processes that hit SIGKILL / OOM / a closed terminal\n+        # before the atexit cleanup hook could run.  Gated to once per\n+        # process so concurrent _create_environment calls (parallel\n+        # subagents, RL benchmarks) don't run the reaper N times.\n+        # Disable via ``terminal.docker_orphan_reaper: false`` (issue #20561).\n+        _maybe_reap_docker_orphans(cc)\n         return _DockerEnvironment(\n             image=image, cwd=cwd, timeout=timeout,\n             cpu=cpu, memory=memory, disk=disk,","@@ -1082,6 +1177,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,\n             env=docker_env,\n             run_as_host_user=cc.get(\"docker_run_as_host_user\", False),\n             extra_args=docker_extra_args,\n+            persist_across_processes=cc.get(\"docker_persist_across_processes\", True),\n         )\n     \n     elif env_type == \"singularity\":","@@ -1323,8 +1419,27 @@ def cleanup_all_environments():\n     return cleaned\n \n \n-def cleanup_vm(task_id: str):\n-    \"\"\"Manually clean up a specific environment by task_id.\"\"\"\n+def cleanup_vm(task_id: str, *, force_remove: bool = False):\n+    \"\"\"Manually clean up a specific environment by task_id.\n+\n+    *force_remove* (default False) is forwarded to backends that accept it\n+    — currently only ``DockerEnvironment``. The default of False matches\n+    session-lifecycle semantics: this function is called from\n+    ``AIAgent.close()`` (TUI session close, gateway session teardown) and the\n+    per-turn cleanup branch for non-persistent envs, both of which should\n+    honor the user's persist-mode preference. Stopping the container here\n+    would defeat the \"ONE long-lived container shared across sessions\"\n+    contract — exactly the bug Ben reported when the container was killed\n+    on every TUI session close.\n+\n+    Pass ``force_remove=True`` for actual user-initiated teardown\n+    (e.g. ``/reset``-style flows that haven't been wired yet, or future\n+    \"destroy my sandbox\" commands).\n+\n+    The idle reaper passes the env through ``env.cleanup()`` directly (not\n+    via this function), so persist-mode idle envs are similarly no-op'd —\n+    only the orphan reaper at next startup reclaims them.\n+    \"\"\"\n     # Remove from tracking dicts while holding the lock, but defer the\n     # actual (potentially slow) env.cleanup() call to outside the lock\n     # so other tool calls aren't blocked.","@@ -1349,7 +1464,14 @@ def cleanup_vm(task_id: str):\n \n     try:\n         if hasattr(env, 'cleanup'):\n-            env.cleanup()\n+            # Pass force_remove only if the env's cleanup() accepts it\n+            # (DockerEnvironment after issue #20561; other backends don't).\n+            import inspect\n+            sig = inspect.signature(env.cleanup)\n+            if \"force_remove\" in sig.parameters:\n+                env.cleanup(force_remove=force_remove)\n+            else:\n+                env.cleanup()\n         elif hasattr(env, 'stop'):\n             env.stop()\n         elif hasattr(env, 'terminate'):","@@ -1371,7 +1493,23 @@ def _atexit_cleanup():\n     if _active_environments:\n         count = len(_active_environments)\n         logger.info(\"Shutting down %d remaining sandbox(es)...\", count)\n+        # Snapshot the env objects BEFORE cleanup_all_environments empties\n+        # the dict; we need them to wait on docker cleanup threads after the\n+        # registry has been cleared.\n+        envs_to_wait = list(_active_environments.values())\n         cleanup_all_environments()\n+        # Block briefly so docker stop/rm actually completes before the\n+        # interpreter exits. Issue #20561 — without this join, the daemon\n+        # cleanup threads were getting torn down mid-`docker stop`, leaving\n+        # Exited containers piled up on the host.\n+        for env in envs_to_wait:\n+            wait_fn = getattr(env, \"wait_for_cleanup\", None)\n+            if wait_fn is None:\n+                continue\n+            try:\n+                wait_fn(timeout=15.0)\n+            except Exception as e:  # never block shutdown on a bad backend\n+                logger.debug(\"wait_for_cleanup raised on exit: %s\", e)\n \n atexit.register(_atexit_cleanup)\n ","@@ -1739,6 +1877,8 @@ def terminal_tool(\n                                 \"docker_env\": config.get(\"docker_env\", {}),\n                                 \"docker_run_as_host_user\": config.get(\"docker_run_as_host_user\", False),\n                                 \"docker_extra_args\": config.get(\"docker_extra_args\", []),\n+                                \"docker_persist_across_processes\": config.get(\"docker_persist_across_processes\", True),\n+                                \"docker_orphan_reaper\": config.get(\"docker_orphan_reaper\", True),\n                             }\n \n                         local_config = None"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -126,7 +126,7 @@ The agent has the same filesystem access as your user account. Use `hermes tools\n \n Runs commands inside a Docker container with security hardening (all capabilities dropped, no privilege escalation, PID limits).\n \n-**Single persistent container, not per-command.** Hermes starts ONE long-lived container on first use and routes every terminal, file, and `execute_code` call through `docker exec` into that same container — across sessions, `/new`, `/reset`, and `delegate_task` subagents — for the lifetime of the Hermes process. Working-directory changes, installed packages, and files in `/workspace` carry over from one tool call to the next, just like a local shell. The container is stopped and removed on shutdown. See **Container lifecycle** below for details.\n+**Single persistent container, shared across Hermes processes.** Hermes starts ONE long-lived container on first use and routes every terminal, file, and `execute_code` call through `docker exec` into that same container — across sessions, `/new`, `/reset`, and `delegate_task` subagents. Working-directory changes, installed packages, files in `/workspace`, and **background processes** all carry over from one tool call to the next, and from one Hermes process to the next. When you close a TUI session, run `/quit`, or start a new `hermes` invocation, the container keeps running and the next Hermes process reuses it via a labeled lookup. See **Container lifecycle** below for the exact teardown rules.\n \n ```yaml\n terminal:","@@ -134,8 +134,11 @@ terminal:\n   docker_image: \"nikolaik/python-nodejs:python3.11-nodejs20\"\n   docker_mount_cwd_to_workspace: false  # Mount launch dir into /workspace\n   docker_run_as_host_user: false   # See \"Running container as host user\" below\n-  docker_forward_env:              # Env vars to forward into container\n+  docker_forward_env:              # Host env vars to forward into container\n     - \"GITHUB_TOKEN\"\n+  docker_env:                      # Literal env vars to inject (KEY=value)\n+    DEBUG: \"1\"\n+    PYTHONUNBUFFERED: \"1\"\n   docker_volumes:                  # Host directory mounts\n     - \"/home/user/projects:/workspace/projects\"\n     - \"/home/user/data:/data:ro\"   # :ro for read-only","@@ -147,14 +150,49 @@ terminal:\n   container_cpu: 1                 # CPU cores (0 = unlimited)\n   container_memory: 5120           # MB (0 = unlimited)\n   container_disk: 51200            # MB (requires overlay2 on XFS+pquota)\n-  container_persistent: true       # Persist /workspace and /root across sessions\n+  container_persistent: true       # Persist /workspace and /root bind-mount dirs\n+\n+  # Cross-process container reuse (defaults match the \"one long-lived\n+  # container shared across sessions\" contract — see Container lifecycle).\n+  docker_persist_across_processes: true   # Reuse container across Hermes restarts\n+  docker_orphan_reaper: true              # Sweep abandoned Exited containers at startup\n+\n+  # Cross-backend lifecycle settings (apply to docker as well)\n+  timeout: 180                     # Per-command timeout in seconds\n+  lifetime_seconds: 300            # Idle-reaper window; also feeds 2× orphan-reaper threshold\n ```\n \n+**`docker_env`** vs **`docker_forward_env`**: the former injects literal `KEY=value` pairs you specify in the config (the values live in your `config.yaml` or are passed as a JSON dict via `TERMINAL_DOCKER_ENV='{\"DEBUG\":\"1\"}'`). The latter forwards values from your shell or `~/.hermes/.env`, so the actual secret never appears in the config file. Use `docker_forward_env` for tokens and `docker_env` for static knobs the container needs.\n+\n **`terminal.docker_extra_args`** (also overridable via `TERMINAL_DOCKER_EXTRA_ARGS='[\"--gpus=all\"]'`) lets you pass arbitrary `docker run` flags that Hermes doesn't surface as first-class keys — `--gpus`, `--network`, `--add-host`, alternative `--security-opt` overrides, etc. Each entry must be a string; the list is appended last to the assembled `docker run` invocation so it can override Hermes' defaults if needed. Use sparingly — flags that conflict with the sandbox hardening (capability drops, `--user`, the workspace bind mount) will silently weaken isolation.\n \n **Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). Podman is supported out of the box: set `HERMES_DOCKER_BINARY=podman` (or the full path) to force it when both are installed.\n \n-**Container lifecycle:** Hermes reuses a single long-lived container (`docker run -d ... sleep 2h`) for every terminal and file-tool call, across sessions, `/new`, `/reset`, and `delegate_task` subagents, for the lifetime of the Hermes process. Commands run via `docker exec` with a login shell, so working-directory changes, installed packages, and files in `/workspace` all persist from one tool call to the next. The container is stopped and removed on Hermes shutdown (or when the idle-sweep reclaims it).\n+#### Container lifecycle\n+\n+Every Hermes-managed container is tagged with three labels so subsequent processes (and the orphan reaper) can identify it:\n+\n+- `hermes-agent=1` — marks it as Hermes-managed\n+- `hermes-task-id=<sanitized task_id>` — keys the per-task reuse probe\n+- `hermes-profile=<sanitized profile name>` — scopes reuse and reaping to the active Hermes profile\n+\n+On startup, Hermes runs `docker ps --filter label=hermes-task-id=<id> --filter label=hermes-profile=<profile>` and **attaches to the existing container** when it finds one. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not.\n+\n+When a Hermes process exits — `/quit`, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a **no-op for the container in default mode**. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the \"one long-lived container shared across sessions\" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions.\n+\n+**The container is only torn down (stopped and `docker rm -f`'d) in these cases:**\n+\n+| Trigger | When it fires |\n+|---|---|\n+| `docker_persist_across_processes: false` | Explicit per-process isolation. Every `cleanup()` does `stop` + `rm -f`. Matches pre-issue-#20561 behavior. |\n+| Idle reaper (`lifetime_seconds`, default 300s) | Only when the env is `persist_across_processes=false`. Persist-mode envs are no-op'd; container survives the idle sweep. |\n+| Orphan reaper at next startup | Sweeps **Exited** hermes-labeled containers older than `2 × lifetime_seconds` (default 600s = 10 min), scoped to the current profile. **Running containers are never touched** — sibling-process safety. Set `docker_orphan_reaper: false` to disable. |\n+| Direct user action | `docker rm -f`, `docker system prune`, Docker Desktop restart. We don't set `--restart=always`, so a host reboot leaves the container `Exited` (its CoW layer survives and gets reused on next startup, but bg processes are gone). |\n+\n+Edge cases worth knowing:\n+\n+- **OOM kill of in-container PID 1** transitions the container to `Exited`. Next reuse will `docker start` it; filesystem state survives, bg processes do not.\n+- **Switching profiles** isolates containers from each other — a container labeled `hermes-profile=work` is invisible to a Hermes process running under `hermes-profile=research`. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile.\n \n Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.\n ","@@ -166,6 +204,29 @@ Parallel subagents spawned via `delegate_task(tasks=[...])` share this one conta\n \n **Credential forwarding:** Env vars listed in `docker_forward_env` are resolved from your shell environment first, then `~/.hermes/.env`. Skills can also declare `required_environment_variables` which are merged automatically.\n \n+#### Environment variable overrides\n+\n+Every key under `terminal:` has an env-var override of the form `TERMINAL_<KEY_UPPERCASE>`. The most useful ones for the Docker backend:\n+\n+| Env var | Maps to | Notes |\n+|---|---|---|\n+| `TERMINAL_DOCKER_IMAGE` | `docker_image` | Base image |\n+| `TERMINAL_DOCKER_FORWARD_ENV` | `docker_forward_env` | JSON array: `'[\"GITHUB_TOKEN\",\"OPENAI_API_KEY\"]'` |\n+| `TERMINAL_DOCKER_ENV` | `docker_env` | JSON dict: `'{\"DEBUG\":\"1\"}'` |\n+| `TERMINAL_DOCKER_VOLUMES` | `docker_volumes` | JSON array of `\"host:container[:ro]\"` strings |\n+| `TERMINAL_DOCKER_EXTRA_ARGS` | `docker_extra_args` | JSON array |\n+| `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | `docker_mount_cwd_to_workspace` | `true` / `false` |\n+| `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` |\n+| `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` |\n+| `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` |\n+| `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores |\n+| `TERMINAL_CONTAINER_MEMORY` | `container_memory` | MB |\n+| `TERMINAL_CONTAINER_DISK` | `container_disk` | MB |\n+| `TERMINAL_CONTAINER_PERSISTENT` | `container_persistent` | `true` / `false` — controls the bind-mount workspace dirs, distinct from `docker_persist_across_processes` |\n+| `TERMINAL_LIFETIME_SECONDS` | `lifetime_seconds` | Idle reaper window |\n+| `TERMINAL_TIMEOUT` | `timeout` | Per-command timeout |\n+| `HERMES_DOCKER_BINARY` | _none_ | Force a specific docker/podman binary path |\n+\n ### SSH Backend\n \n Runs commands on a remote server over SSH. Uses ControlMaster for connection reuse (5-minute idle keepalive). Persistent shell is enabled by default — state (cwd, env vars) survives across commands."]},{"file":"website/docs/user-guide/docker.md","hunks":["@@ -224,7 +224,7 @@ docker run -it --rm \\\n Direct `-e` flags override values from `.env`. This is useful for CI/CD or secrets-manager integrations where you don't want keys on disk.\n \n :::note Looking for Docker as the **terminal backend**?\n-This page covers running Hermes itself inside Docker. If you want Hermes to execute the agent's `terminal` / `execute_code` calls inside a Docker sandbox container (one persistent container per Hermes process), that's a separate config block — `terminal.backend: docker` plus `terminal.docker_image`, `terminal.docker_volumes`, `terminal.docker_forward_env`, `terminal.docker_run_as_host_user`, and `terminal.docker_extra_args`. See [Configuration → Docker Backend](configuration.md#docker-backend) for the full set.\n+This page covers running Hermes itself inside Docker. If you want Hermes to execute the agent's `terminal` / `execute_code` calls inside a Docker sandbox container (one long-lived container shared across Hermes processes — see issue #20561), that's a separate config block — `terminal.backend: docker` plus `terminal.docker_image`, `terminal.docker_volumes`, `terminal.docker_forward_env`, `terminal.docker_env`, `terminal.docker_run_as_host_user`, `terminal.docker_extra_args`, `terminal.docker_persist_across_processes`, and `terminal.docker_orphan_reaper`. See [Configuration → Docker Backend](configuration.md#docker-backend) for the full set including container-lifecycle rules.\n :::\n \n ## Docker Compose example\n"]}]},{"kind":"pr","number":39519,"title":"fix(cli): clean up Docker sandbox environments on /stop","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"d682f320b35a13084371a541a835e1d988c982b8","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44142,"title":"fix(tools): delete a reaped docker container's sandbox dir to stop the disk leak","url":null,"state":"open","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44144,"title":"fix(docker): clean up sandbox dirs when reaping orphan containers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44142","number":44142},{"kind":"pr","value":"#44142","number":44142}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}