{"repo":"NousResearch/hermes-agent","public_id":"19563","display_label":"Persistent reconnectable terminal sessions in dashboard","summary":"These issues and PRs concern making dashboard/webchat terminal sessions survive browser disconnects, refreshes, and tab closures, with keep-alive and reattach capabilities. Related work includes offloading PTY operations off the event loop to support this behavior.","subsystem":"apps/desktop","counts":{"members":8,"open_issues":3,"open_issues_distinct":3,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53227},"resolution":{"state":"partially_resolved","by":"pr-60515"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T22:15:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1942857142857143},"fix_edges":[{"issue_number":15915,"pr_number":60515,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22027,"pr_number":60515,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":50074,"pr_number":53227,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":50074,"pr_number":60515,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":19563,"title":"Feature Request: Persistent background tasks with web UI control and cross-session resumption","url":null,"state":"open"},{"kind":"issue","number":22027,"title":"[Feature]: Persistent agent sessions — tasks should continue when webchat browser tab is closed","url":null,"state":"closed"},{"kind":"issue","number":25738,"title":"Dashboard chat runs should survive browser disconnects and persist results to session history","url":null,"state":"open"},{"kind":"issue","number":50074,"title":"Dashboard terminals don't survive a browser refresh (chat session is lost)","url":null,"state":"closed"},{"kind":"issue","number":53839,"title":"Feature request: durable reconnectable runs for Desktop and Web Dashboard","url":null,"state":"open"},{"kind":"pr","number":50084,"title":"feat(chat): keep-alive + reattach for dashboard terminal sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e10e4bca8","number":0},{"kind":"commit","value":"79f4f78fa","number":0},{"kind":"pr","value":"#60515","number":60515},{"kind":"pr","value":"#53227","number":53227}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53227,"title":" fix(dashboard): offload PTY spawn/close off the event loop","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -11904,7 +11904,7 @@ async def pty_ws(ws: WebSocket) -> None:\n \n \n     try:\n-        bridge = PtyBridge.spawn(argv, cwd=cwd, env=env)\n+        bridge = await asyncio.to_thread(PtyBridge.spawn, argv, cwd=cwd, env=env)\n     except PtyUnavailableError as exc:\n         await ws.send_text(f\"\\r\\n\\x1b[31mChat unavailable: {exc}\\x1b[0m\\r\\n\")\n         await ws.close(code=1011)","@@ -11965,7 +11965,7 @@ async def pump_pty_to_ws() -> None:\n             await reader_task\n         except (asyncio.CancelledError, Exception):\n             pass\n-        bridge.close()\n+        await asyncio.to_thread(bridge.close)\n \n \n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":60515,"title":"feat(chat): keep-alive + reattach for dashboard terminal sessions (#50084 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/pty_session.py","hermes_cli/web_server.py","tests/test_pty_keepalive_ws.py","tests/test_pty_session.py","web/src/pages/ChatPage.tsx"],"fix_hunks":[{"file":"hermes_cli/pty_session.py","hunks":["@@ -0,0 +1,195 @@\n+\"\"\"Keep-alive PTY sessions for dashboard terminals.\n+\n+A PTY process outlives the WebSocket that created it: a single drain task\n+always reads the PTY into a bounded RingBuffer and forwards to the attached\n+socket when present. Reconnecting with the same opaque token replays the\n+buffer and resumes live. See\n+docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md.\n+\"\"\"\n+from __future__ import annotations\n+\n+import asyncio\n+import time\n+from typing import Optional\n+\n+WS_CLOSE_PROCESS_EXITED = 4410\n+WS_CLOSE_SUPERSEDED = 4409\n+\n+\n+class RingBuffer:\n+    \"\"\"Keeps only the most recent ``capacity`` bytes appended to it.\"\"\"\n+\n+    def __init__(self, capacity: int) -> None:\n+        self._cap = capacity\n+        self._buf = bytearray()\n+        self._truncated = False\n+\n+    def append(self, data: bytes) -> None:\n+        self._buf.extend(data)\n+        overflow = len(self._buf) - self._cap\n+        if overflow > 0:\n+            del self._buf[:overflow]\n+            self._truncated = True\n+\n+    def snapshot(self) -> bytes:\n+        return bytes(self._buf)\n+\n+    @property\n+    def truncated(self) -> bool:\n+        return self._truncated\n+\n+\n+class PtySession:\n+    def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float) -> None:\n+        self.key = key\n+        self.bridge = bridge\n+        self.buffer = RingBuffer(buffer_cap)\n+        self.alive = True\n+        self.attached = False\n+        self.last_detached_at: Optional[float] = None\n+        self._read_timeout = read_timeout\n+        self._ws = None\n+        self._drain_task: Optional[asyncio.Task] = None\n+\n+    async def start(self) -> None:\n+        self._drain_task = asyncio.create_task(self._drain())\n+\n+    async def _drain(self) -> None:\n+        loop = asyncio.get_running_loop()\n+        while True:\n+            chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout)\n+            if chunk is None:                       # EOF — the agent process exited\n+                self.alive = False\n+                ws = self._ws\n+                if ws is not None:\n+                    try:\n+                        await ws.close(code=WS_CLOSE_PROCESS_EXITED)\n+                    except Exception:\n+                        pass\n+                return\n+            if not chunk:                            # idle tick\n+                await asyncio.sleep(0)\n+                continue\n+            self.buffer.append(chunk)\n+            ws = self._ws\n+            if ws is not None:\n+                try:\n+                    await ws.send_bytes(chunk)\n+                except Exception:\n+                    pass                             # detached mid-send; keep buffering\n+\n+    async def attach(self, ws) -> None:\n+        old = self._ws\n+        if old is not None and old is not ws:\n+            try:\n+                await old.close(code=WS_CLOSE_SUPERSEDED)\n+            except Exception:\n+                pass\n+        self._ws = ws\n+        self.attached = True\n+        self.last_detached_at = None\n+        snap = self.buffer.snapshot()\n+        if snap:\n+            await ws.send_bytes(snap)\n+\n+    def detach(self, ws) -> None:\n+        # Only the currently-attached socket may mark the session detached.\n+        # A superseded socket's handler also calls detach on its way out\n+        # (its ``finally`` runs after the new tab attached); flipping\n+        # ``attached`` then would make a session with a live viewer look\n+        # idle and reapable.\n+        if self._ws is not ws:\n+            return\n+        self._ws = None\n+        self.attached = False\n+        self.last_detached_at = time.monotonic()\n+\n+    async def close(self) -> None:\n+        if self._drain_task is not None:\n+            self._drain_task.cancel()\n+            try:\n+                await self._drain_task\n+            except (asyncio.CancelledError, Exception):\n+                pass\n+        try:\n+            # bridge.close() joins the child — blocking; keep it off the\n+            # event loop (#53227).\n+            await asyncio.to_thread(self.bridge.close)\n+        except Exception:\n+            pass\n+\n+\n+from typing import Callable, Dict, Tuple\n+\n+\n+class RegistryFull(Exception):\n+    pass\n+\n+\n+async def run_reaper(registry: \"PtySessionRegistry\", *, interval: float = 60.0) -> None:\n+    \"\"\"Periodically reap idle/dead keep-alive sessions. Cancelled on shutdown.\"\"\"\n+    while True:\n+        await asyncio.sleep(interval)\n+        try:\n+            await registry.reap_idle()\n+        except Exception:\n+            pass\n+\n+\n+class PtySessionRegistry:\n+    def __init__(self, *, ttl: float, max_sessions: int,\n+                 buffer_cap: int, read_timeout: float) -> None:\n+        self._ttl = ttl\n+        self._max = max_sessions\n+        self._buffer_cap = buffer_cap\n+        self._read_timeout = read_timeout\n+        self._sessions: Dict[str, PtySession] = {}\n+\n+    async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object]\n+                              ) -> Tuple[PtySession, bool]:\n+        await self.reap_idle()\n+        existing = self._sessions.get(key)\n+        if existing is not None and existing.alive:\n+            return existing, False\n+        if existing is not None:                       # dead remnant\n+            await existing.close()\n+            self._sessions.pop(key, None)\n+        if len(self._sessions) >= self._max:\n+            self._reap_one_idle_or_raise()\n+        # PTY spawn does blocking fork/exec work — keep it off the event\n+        # loop (#53227).\n+        bridge = await asyncio.to_thread(spawn)\n+        session = PtySession(key, bridge, buffer_cap=self._buffer_cap,\n+                             read_timeout=self._read_timeout)\n+        await session.start()\n+        self._sessions[key] = session\n+        return session, True\n+\n+    def detach(self, key: str, ws) -> None:\n+        s = self._sessions.get(key)\n+        if s is not None:\n+            s.detach(ws)\n+\n+    async def reap_idle(self, now: Optional[float] = None) -> None:\n+        now = time.monotonic() if now is None else now\n+        doomed = [\n+            key for key, s in self._sessions.items()\n+            if (not s.alive)\n+            or (not s.attached and s.last_detached_at is not None\n+                and (now - s.last_detached_at) > self._ttl)\n+        ]\n+        for key in doomed:\n+            await self._sessions.pop(key).close()\n+\n+    def _reap_one_idle_or_raise(self) -> None:\n+        idle = [s for s in self._sessions.values()\n+                if not s.attached and s.last_detached_at is not None]\n+        if not idle:\n+            raise RegistryFull()\n+        oldest = min(idle, key=lambda s: s.last_detached_at or 0.0)\n+        self._sessions.pop(oldest.key, None)\n+        asyncio.create_task(oldest.close())\n+\n+    async def close_all(self) -> None:\n+        for key in list(self._sessions):\n+            await self._sessions.pop(key).close()"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -204,9 +204,14 @@ async def _lifespan(app: \"FastAPI\"):\n         )\n         cron_thread.start()\n \n+    # Reap idle/dead keep-alive PTY sessions in the background (30-min TTL).\n+    pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY))\n+\n     try:\n         yield\n     finally:\n+        pty_reaper_task.cancel()\n+        await PTY_REGISTRY.close_all()\n         if cron_stop is not None:\n             cron_stop.set()\n ","@@ -12538,6 +12543,105 @@ class PtyUnavailableError(RuntimeError):  # type: ignore[no-redef]\n \n _RESIZE_RE = re.compile(rb\"\\x1b\\[RESIZE:(\\d+);(\\d+)\\]\")\n _PTY_READ_CHUNK_TIMEOUT = 0.2\n+\n+# Keep-alive PTY sessions: a terminal connecting with ``?attach=<token>`` is\n+# bound to a process that survives disconnect/refresh and is reattachable.\n+from hermes_cli.pty_session import PtySessionRegistry, RegistryFull, run_reaper  # noqa: E402\n+\n+PTY_REGISTRY = PtySessionRegistry(\n+    ttl=30 * 60,\n+    max_sessions=16,\n+    buffer_cap=1 * 1024 * 1024,\n+    read_timeout=_PTY_READ_CHUNK_TIMEOUT,\n+)\n+\n+\n+async def _legacy_pump(ws: \"WebSocket\", bridge) -> None:\n+    \"\"\"Original 1:1 socket<->PTY pump: stream until disconnect, then close the\n+    bridge. Used when no ``?attach=`` token is supplied (keep-alive opt-in).\n+\n+    Behavior is identical to the pre-keep-alive ``pty_ws`` body, including the\n+    #54028 half-open-socket protection (reader EOF → close the WS so the\n+    writer's ``ws.receive()`` unparks) and the #53227 ``to_thread`` offloads\n+    for the blocking ``bridge.close()``.\n+    \"\"\"\n+    loop = asyncio.get_running_loop()\n+\n+    # --- reader task: PTY master → WebSocket ----------------------------\n+    async def pump_pty_to_ws() -> None:\n+        try:\n+            while True:\n+                chunk = await loop.run_in_executor(\n+                    None, bridge.read, _PTY_READ_CHUNK_TIMEOUT\n+                )\n+                if chunk is None:  # EOF\n+                    return\n+                if not chunk:  # no data this tick; yield control and retry\n+                    await asyncio.sleep(0)\n+                    continue\n+                try:\n+                    await ws.send_bytes(chunk)\n+                except Exception:\n+                    return\n+        finally:\n+            # The child has exited (EOF) or the send side broke.  Close the\n+            # WebSocket so the writer loop's ``ws.receive()`` returns instead\n+            # of blocking forever — otherwise, when the browser's socket is\n+            # half-open (no FIN delivered, common on macOS/launchd) the\n+            # handler never reaches its ``finally`` and the PTY's fds leak.\n+            # With dashboard auto-reconnect (#52962) every dropped socket then\n+            # stacks a fresh PTY on top of the orphaned one, exhausting fds.\n+            #\n+            # Reap the bridge here too (close() is idempotent): on child EOF the\n+            # writer loop's ``finally`` is the usual closer, but if the handler\n+            # task is cancelled the instant we close the WS, that ``finally``\n+            # can be skipped, leaking the PTY. Closing from the EOF path makes\n+            # the reap independent of that cancellation race (#54028).\n+            try:\n+                await asyncio.to_thread(bridge.close)\n+            except Exception:\n+                pass\n+            try:\n+                await ws.close()\n+            except Exception:\n+                pass\n+\n+    reader_task = asyncio.create_task(pump_pty_to_ws())\n+\n+    # --- writer loop: WebSocket → PTY master ----------------------------\n+    try:\n+        while True:\n+            try:\n+                msg = await ws.receive()\n+            except RuntimeError:\n+                # Raised when ws.receive() is called after the socket is\n+                # already disconnected (e.g. closed by the reader task above).\n+                break\n+            if msg.get(\"type\") == \"websocket.disconnect\":\n+                break\n+            raw = msg.get(\"bytes\")\n+            if raw is None:\n+                text = msg.get(\"text\")\n+                raw = text.encode(\"utf-8\") if isinstance(text, str) else b\"\"\n+            if not raw:\n+                continue\n+            # Resize escape is consumed locally, never written to the PTY.\n+            match = _RESIZE_RE.match(raw)\n+            if match and match.end() == len(raw):\n+                bridge.resize(cols=int(match.group(1)), rows=int(match.group(2)))\n+                continue\n+            bridge.write(raw)\n+    except WebSocketDisconnect:\n+        pass\n+    finally:\n+        reader_task.cancel()\n+        try:\n+            await reader_task\n+        except (asyncio.CancelledError, Exception):\n+            pass\n+        await asyncio.to_thread(bridge.close)\n+\n+\n _VALID_CHANNEL_RE = re.compile(r\"^[A-Za-z0-9._-]{1,128}$\")\n # Starlette's TestClient reports the peer as \"testclient\"; treat it as\n # loopback so tests don't need to rewrite request scope.","@@ -13743,71 +13847,57 @@ async def pty_ws(ws: WebSocket) -> None:\n         return\n \n \n+    attach_token = [redacted:secret-assignment]\"attach\") or None\n+\n+    def _spawn():\n+        return PtyBridge.spawn(argv, cwd=cwd, env=env)\n+\n+    if attach_token is None:\n+        # Legacy path: 1:1 socket<->PTY, killed on disconnect (unchanged).\n+        try:\n+            bridge = _spawn()\n+        except PtyUnavailableError as exc:\n+            await ws.send_text(f\"\\r\\n\\x1b[31mChat unavailable: {exc}\\x1b[0m\\r\\n\")\n+            await ws.close(code=1011)\n+            return\n+        except (FileNotFoundError, OSError) as exc:\n+            await ws.send_text(f\"\\r\\n\\x1b[31mChat failed to start: {exc}\\x1b[0m\\r\\n\")\n+            await ws.close(code=1011)\n+            return\n+        await _legacy_pump(ws, bridge)\n+        return\n+\n+    # Keep-alive path: the PTY outlives this socket; reattach by token.\n     try:\n-        bridge = await asyncio.to_thread(PtyBridge.spawn, argv, cwd=cwd, env=env)\n+        session, _created = await PTY_REGISTRY.attach_or_spawn(\n+            attach_token, spawn=_spawn\n+        )\n     except PtyUnavailableError as exc:\n         await ws.send_text(f\"\\r\\n\\x1b[31mChat unavailable: {exc}\\x1b[0m\\r\\n\")\n         await ws.close(code=1011)\n         return\n-    except (FileNotFoundError, OSError) as exc:\n-        await ws.send_text(f\"\\r\\n\\x1b[31mChat failed to start: {exc}\\x1b[0m\\r\\n\")\n+    except (FileNotFoundError, OSError, RegistryFull) as exc:\n+        await ws.send_text(f\"\\r\\n\\x1b[31mChat unavailable: {exc}\\x1b[0m\\r\\n\")\n         await ws.close(code=1011)\n         return\n \n-    loop = asyncio.get_running_loop()\n-\n-    # --- reader task: PTY master → WebSocket ----------------------------\n-    async def pump_pty_to_ws() -> None:\n-        try:\n-            while True:\n-                chunk = await loop.run_in_executor(\n-                    None, bridge.read, _PTY_READ_CHUNK_TIMEOUT\n-                )\n-                if chunk is None:  # EOF\n-                    return\n-                if not chunk:  # no data this tick; yield control and retry\n-                    await asyncio.sleep(0)\n-                    continue\n-                try:\n-                    await ws.send_bytes(chunk)\n-                except Exception:\n-                    return\n-        finally:\n-            # The child has exited (EOF) or the send side broke.  Close the\n-            # WebSocket so the writer loop's ``ws.receive()`` returns instead\n-            # of blocking forever — otherwise, when the browser's socket is\n-            # half-open (no FIN delivered, common on macOS/launchd) the\n-            # handler never reaches its ``finally`` and the PTY's fds leak.\n-            # With dashboard auto-reconnect (#52962) every dropped socket then\n-            # stacks a fresh PTY on top of the orphaned one, exhausting fds.\n-            #\n-            # Reap the bridge here too (close() is idempotent): on child EOF the\n-            # writer loop's ``finally`` is the usual closer, but if the handler\n-            # task is cancelled the instant we close the WS, that ``finally``\n-            # can be skipped, leaking the PTY. Closing from the EOF path makes\n-            # the reap independent of that cancellation race (#54028).\n-            try:\n-                await asyncio.to_thread(bridge.close)\n-            except Exception:\n-                pass\n-            try:\n-                await ws.close()\n-            except Exception:\n-                pass\n-\n-    reader_task = asyncio.create_task(pump_pty_to_ws())\n+    await session.attach(ws)\n \n     # --- writer loop: WebSocket → PTY master ----------------------------\n+    # No reader task here: the session's drain task (spawned once per PTY,\n+    # inside the registry) forwards PTY output to whichever socket is\n+    # attached and rings-buffers it while detached.  On child EOF the drain\n+    # closes the attached socket with 4410, which unparks ``ws.receive()``\n+    # below — same half-open-socket protection the legacy pump has (#54028).\n     try:\n         while True:\n             try:\n                 msg = await ws.receive()\n             except RuntimeError:\n-                # Raised when ws.receive() is called after the socket is\n-                # already disconnected (e.g. closed by the reader task above).\n+                # ws.receive() after the socket is already disconnected\n+                # (e.g. closed by the drain task on process exit).\n                 break\n-            msg_type = msg.get(\"type\")\n-            if msg_type == \"websocket.disconnect\":\n+            if msg.get(\"type\") == \"websocket.disconnect\":\n                 break\n             raw = msg.get(\"bytes\")\n             if raw is None:","@@ -13819,21 +13909,16 @@ async def pump_pty_to_ws() -> None:\n             # Resize escape is consumed locally, never written to the PTY.\n             match = _RESIZE_RE.match(raw)\n             if match and match.end() == len(raw):\n-                cols = int(match.group(1))\n-                rows = int(match.group(2))\n-                bridge.resize(cols=cols, rows=rows)\n+                session.bridge.resize(cols=int(match.group(1)), rows=int(match.group(2)))\n                 continue\n \n-            bridge.write(raw)\n+            session.bridge.write(raw)\n     except WebSocketDisconnect:\n         pass\n     finally:\n-        reader_task.cancel()\n-        try:\n-            await reader_task\n-        except (asyncio.CancelledError, Exception):\n-            pass\n-        await asyncio.to_thread(bridge.close)\n+        # Detach only — the PTY keeps running for a reattach; the registry\n+        # reaper closes it after the TTL (or immediately on process exit).\n+        PTY_REGISTRY.detach(attach_token, ws)\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/test_pty_keepalive_ws.py","hunks":["@@ -0,0 +1,53 @@\n+import pytest\n+\n+from hermes_cli import web_server\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_same_session(monkeypatch):\n+    \"\"\"Two connects with the same ?attach= token hit one spawned bridge.\"\"\"\n+    spawned = []\n+\n+    class FakeBridge:\n+        def __init__(self):\n+            self.alive = True\n+\n+        def read(self, timeout):\n+            return b\"\"        # idle forever\n+\n+        def write(self, data):\n+            pass\n+\n+        def resize(self, cols, rows):\n+            pass\n+\n+        def close(self):\n+            self.alive = False\n+\n+    def fake_spawn(argv, cwd=None, env=None):\n+        b = FakeBridge()\n+        spawned.append(b)\n+        return b\n+\n+    monkeypatch.setattr(web_server.PtyBridge, \"spawn\", staticmethod(fake_spawn))\n+    # bypass auth + argv resolution for the test\n+    monkeypatch.setattr(web_server, \"_ws_auth_reason\", lambda ws: (None, \"test\"))\n+    monkeypatch.setattr(web_server, \"_ws_host_origin_reason\", lambda ws: None)\n+    monkeypatch.setattr(web_server, \"_ws_client_reason\", lambda ws: None)\n+\n+    async def fake_argv(**kw):\n+        return ([\"x\"], \"/tmp\", {})\n+\n+    monkeypatch.setattr(web_server, \"_resolve_chat_argv_async\", fake_argv)\n+\n+    from starlette.testclient import TestClient\n+\n+    try:\n+        client = TestClient(web_server.app)\n+        with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws1:\n+            ws1.send_bytes(b\"hi\")\n+        with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws2:\n+            ws2.send_bytes(b\"again\")\n+        assert len(spawned) == 1                # reattached, did not respawn\n+    finally:\n+        web_server.PTY_REGISTRY._sessions.clear()"]},{"file":"tests/test_pty_session.py","hunks":["@@ -0,0 +1,182 @@\n+import asyncio\n+import time\n+\n+import pytest\n+\n+from hermes_cli.pty_session import RingBuffer\n+\n+\n+def test_ringbuffer_keeps_everything_under_capacity():\n+    rb = RingBuffer(10)\n+    rb.append(b\"abc\")\n+    rb.append(b\"def\")\n+    assert rb.snapshot() == b\"abcdef\"\n+    assert rb.truncated is False\n+\n+\n+def test_ringbuffer_drops_oldest_over_capacity():\n+    rb = RingBuffer(4)\n+    rb.append(b\"abcdef\")          # 6 bytes into a 4-byte buffer\n+    assert rb.snapshot() == b\"cdef\"\n+    assert rb.truncated is True\n+\n+\n+def test_ringbuffer_truncation_across_appends():\n+    rb = RingBuffer(3)\n+    rb.append(b\"ab\")\n+    rb.append(b\"cd\")             # now \"abcd\" -> keep \"bcd\"\n+    assert rb.snapshot() == b\"bcd\"\n+    assert rb.truncated is True\n+\n+\n+class FakeBridge:\n+    \"\"\"Implements the bridge contract PtySession depends on.\"\"\"\n+\n+    def __init__(self, chunks):\n+        self._chunks = list(chunks)   # bytes; b\"\" = idle tick; None = EOF\n+        self.written = bytearray()\n+        self.closed = False\n+        self.resized = None\n+\n+    def read(self, timeout):\n+        if not self._chunks:\n+            return b\"\"                # idle\n+        return self._chunks.pop(0)\n+\n+    def write(self, data):\n+        self.written.extend(data)\n+\n+    def resize(self, cols, rows):\n+        self.resized = (cols, rows)\n+\n+    def close(self):\n+        self.closed = True\n+\n+\n+class FakeWS:\n+    def __init__(self):\n+        self.sent = []               # list of (\"bytes\"|\"text\", payload)\n+        self.close_code = None\n+\n+    async def send_bytes(self, data):\n+        self.sent.append((\"bytes\", bytes(data)))\n+\n+    async def send_text(self, text):\n+        self.sent.append((\"text\", text))\n+\n+    async def close(self, code=1000, reason=\"\"):\n+        self.close_code = code\n+\n+\n[redacted:email]\n+async def test_attach_replays_buffer_then_streams_live():\n+    from hermes_cli.pty_session import PtySession\n+    bridge = FakeBridge([b\"hello \", b\"world\", None])\n+    s = PtySession(\"k\", bridge, buffer_cap=1024, read_timeout=0.01)\n+    await s.start()\n+    await asyncio.sleep(0.05)                      # drain consumes \"hello world\"\n+    ws = FakeWS()\n+    await s.attach(ws)\n+    replay = b\"\".join(p for kind, p in ws.sent if kind == \"bytes\")\n+    assert replay == b\"hello world\"\n+    await s.close()\n+\n+\n[redacted:email]\n+async def test_detach_keeps_draining_into_buffer():\n+    from hermes_cli.pty_session import PtySession\n+    bridge = FakeBridge([b\"one\", b\"\", b\"two\"])\n+    s = PtySession(\"k\", bridge, buffer_cap=1024, read_timeout=0.01)\n+    await s.start()\n+    ws = FakeWS()\n+    await s.attach(ws)\n+    s.detach(ws)\n+    assert s.attached is False\n+    assert s.last_detached_at is not None\n+    await asyncio.sleep(0.05)                      # \"two\" drains while detached\n+    ws2 = FakeWS()\n+    await s.attach(ws2)\n+    replay = b\"\".join(p for kind, p in ws2.sent if kind == \"bytes\")\n+    assert replay == b\"onetwo\"\n+    await s.close()\n+\n+\n[redacted:email]\n+async def test_eof_marks_dead_and_closes_socket_4410():\n+    from hermes_cli.pty_session import PtySession\n+    bridge = FakeBridge([b\"bye\", None])\n+    s = PtySession(\"k\", bridge, buffer_cap=1024, read_timeout=0.01)\n+    await s.start()\n+    ws = FakeWS()\n+    await s.attach(ws)\n+    await asyncio.sleep(0.05)                      # drain hits None (EOF)\n+    assert s.alive is False\n+    assert ws.close_code == 4410\n+    await s.close()\n+\n+\n+from hermes_cli.pty_session import PtySessionRegistry, RegistryFull\n+\n+\n+def make_registry(ttl=1800.0, max_sessions=16):\n+    return PtySessionRegistry(ttl=ttl, max_sessions=max_sessions,\n+                              buffer_cap=1024, read_timeout=0.01)\n+\n+\n[redacted:email]\n+async def test_same_key_reattaches_same_session():\n+    reg = make_registry()\n+    b1 = FakeBridge([b\"\", b\"\", b\"\"])\n+    s1, created1 = await reg.attach_or_spawn(\"tok\", spawn=lambda: b1)\n+    s2, created2 = await reg.attach_or_spawn(\"tok\", spawn=lambda: FakeBridge([]))\n+    assert created1 is True and created2 is False\n+    assert s1 is s2\n+    assert s2.bridge is b1                     # second spawn callable was NOT used\n+    await reg.close_all()\n+\n+\n[redacted:email]\n+async def test_reap_idle_closes_sessions_past_ttl():\n+    reg = make_registry(ttl=10.0)\n+    b = FakeBridge([b\"\", b\"\"])\n+    s, _ = await reg.attach_or_spawn(\"tok\", spawn=lambda: b)\n+    ws = FakeWS()\n+    await s.attach(ws)\n+    s.detach(ws)\n+    s.last_detached_at = time.monotonic() - 11.0   # detached 11s ago, ttl 10s\n+    await reg.reap_idle()\n+    assert b.closed is True\n+    s2, created = await reg.attach_or_spawn(\"tok\", spawn=lambda: FakeBridge([]))\n+    assert created is True\n+    await reg.close_all()\n+\n+\n[redacted:email]\n+async def test_new_key_at_capacity_raises_when_none_reapable():\n+    reg = make_registry(max_sessions=1)\n+    b = FakeBridge([b\"\", b\"\"])\n+    s, _ = await reg.attach_or_spawn(\"a\", spawn=lambda: b)\n+    await s.attach(FakeWS())                    # attached → not reapable\n+    with pytest.raises(RegistryFull):\n+        await reg.attach_or_spawn(\"b\", spawn=lambda: FakeBridge([]))\n+    await reg.close_all()\n+\n+\n[redacted:email]\n+async def test_reaper_loop_invokes_reap(monkeypatch):\n+    from hermes_cli.pty_session import run_reaper\n+    reg = make_registry()\n+    calls = {\"n\": 0}\n+\n+    async def fake_reap(now=None):\n+        calls[\"n\"] += 1\n+\n+    monkeypatch.setattr(reg, \"reap_idle\", fake_reap)\n+    task = asyncio.create_task(run_reaper(reg, interval=0.01))\n+    await asyncio.sleep(0.05)\n+    task.cancel()\n+    try:\n+        await task\n+    except asyncio.CancelledError:\n+        pass\n+    assert calls[\"n\"] >= 2"]},{"file":"web/src/pages/ChatPage.tsx","hunks":["@@ -40,6 +40,34 @@ import { PluginSlot } from \"@/plugins\";\n import { useTheme } from \"@/themes\";\n import { useProfileScope } from \"@/contexts/useProfileScope\";\n \n+// Stable per-browser token identifying THIS chat tab's keep-alive PTY session.\n+// Sent as ?attach=; lets a refresh/disconnect reattach to the same live process\n+// instead of spawning a fresh one. Per-localStorage, so other devices can't grab it.\n+// ``rotate`` mints a new token — used when the user explicitly starts a fresh\n+// session so the old keep-alive PTY is NOT reattached (the registry reaps it).\n+const PTY_ATTACH_TOKEN_KEY = \"[redacted:secret-assignment]\";\n+function ptyAttachToken(rotate = false): string {\n+  let t = \"\";\n+  if (!rotate) {\n+    try {\n+      t = window.localStorage.getItem(PTY_ATTACH_TOKEN_KEY) ?? \"\";\n+    } catch {\n+      /* private mode / storage blocked */\n+    }\n+  }\n+  if (!t) {\n+    const a = new Uint8Array(16);\n+    crypto.getRandomValues(a);\n+    t = Array.from(a, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n+    try {\n+      window.localStorage.setItem(PTY_ATTACH_TOKEN_KEY, t);\n+    } catch {\n+      /* ignore */\n+    }\n+  }\n+  return t;\n+}\n+\n // Channel id ties this chat tab's PTY child (publisher) to its sidebar\n // (subscriber).  Generated once per mount so a tab refresh starts a fresh\n // channel — the previous PTY child terminates with the old WS, and its","@@ -679,6 +707,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n       const params: Record<string, string> = { channel };\n       if (resumeParam) params.resume = resumeParam;\n       if (forceFresh) params.fresh = \"1\";\n+      // Keep-alive identity: reattach to this tab's living PTY across\n+      // refresh/transient drops. A forced-fresh start rotates the token so\n+      // the previous keep-alive PTY is not reattached (registry reaps it).\n+      params.attach = ptyAttachToken(forceFresh);\n       // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the\n       // selected profile, so the conversation runs with that profile's model,\n       // skills, memory, and sessions (see web_server._resolve_chat_argv).","@@ -693,6 +725,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n       reconnectAttemptRef.current = 0;\n       setBanner(null);\n       setSessionEnded(false);\n+      // Connected — cancel any pending reconnect from a prior transient drop.\n+      if (reconnectTimerRef.current) {\n+        clearTimeout(reconnectTimerRef.current);\n+        reconnectTimerRef.current = null;\n+      }\n       // Send the initial RESIZE immediately so Ink has *a* size to lay\n       // out against on its first paint.  The double-rAF block above will\n       // follow up with the authoritative measurement — at worst Ink","@@ -775,7 +812,21 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n         // Server already wrote an ANSI error frame.\n         return;\n       }\n+      // Keep-alive close-code contract (web_server.pty_ws + pty_session):\n+      //   4410 = the agent PROCESS exited (real end) → restart affordance.\n+      //   4409 = superseded by a newer tab attaching the same token → stay quiet.\n+      if (ev.code === 4410) {\n+        term.write(`\\r\\n\\x1b[90m[session ended]\\x1b[0m\\r\\n`);\n+        setSessionEnded(true);\n+        return;\n+      }\n+      if (ev.code === 4409) {\n+        return;\n+      }\n       if (!ev.wasClean || ev.code === 1001 || ev.code === 1006) {\n+        // Transient transport drop (refresh, sleep/wake, signal loss).\n+        // Reconnect with backoff; the same ?attach= token reattaches to\n+        // the still-living PTY, so the conversation continues in place.\n         scheduleReconnect(ev.code);\n         return;\n       }","@@ -854,6 +905,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n         clearTimeout(copyResetRef.current);\n         copyResetRef.current = null;\n       }\n+      if (reconnectTimerRef.current) {\n+        clearTimeout(reconnectTimerRef.current);\n+        reconnectTimerRef.current = null;\n+      }\n     };\n   }, [channel, clearReconnectTimer, resumeParam, scopedProfile, reconnectNonce]);\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}