{"repo":"NousResearch/hermes-agent","public_id":"15915","display_label":"Dashboard chat session resume and PTY reattach issues","summary":"These issues and fixes center on the web dashboard's chat tab failing to correctly resume, switch, or display historical sessions, largely due to PTY keep-alive tokens and reattach logic not properly accounting for profile changes, resume targets, or URL parameters. Several fixes address session picker behavior, scrollback rendering, model picker sync, and state persistence across tab switches.","subsystem":"dashboard","counts":{"members":32,"open_issues":1,"open_issues_distinct":1,"open_prs":6,"closed_issues":12,"merged_prs":4,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49269},"resolution":{"state":"partially_resolved","by":"pr-49269,pr-60515,pr-60745,pr-133941"},"freshness":{"state":"verified","last_verified_at":"2026-10-06T16:24:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.05959677419354838},"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":44343,"pr_number":49269,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":45107,"pr_number":49269,"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},{"issue_number":61284,"pr_number":60745,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62045,"pr_number":133941,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":64154,"pr_number":60745,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":15915,"title":"Dashboard: Navigation sidebar refreshes page and loses chat state","url":null,"state":"closed"},{"kind":"issue","number":16667,"title":"[Bug]: webui: Chat sidebar model picker changes don't take effect in chat until tab switch","url":null,"state":"closed"},{"kind":"issue","number":18441,"title":"Bug: Dashboard Chat resume button (triangle) does not restore session — resumeRef not synced with URL params","url":null,"state":"closed"},{"kind":"issue","number":19085,"title":"dashboard Sessions -> Chat resume does not reliably load the selected historical session","url":null,"state":"closed"},{"kind":"issue","number":44343,"title":"Everytime you visit the CHAT tab of the dashboard it creates a new empty session.","url":null,"state":"closed"},{"kind":"issue","number":60868,"title":"[Bug]: Dashboard lists sessions but shows no conversation messages after v0.18.0 update","url":null,"state":"open"},{"kind":"issue","number":61284,"title":"[Bug]: Dashboard chat sessions fail to render due to recent WebSocket regression","url":null,"state":"closed"},{"kind":"issue","number":61919,"title":"[Bug]: Dashboard Chat session switch keeps showing old conversation (keep-alive PTY attach token ignores new resume id)","url":null,"state":"closed"},{"kind":"issue","number":62045,"title":"[Bug]: Dashboard chat session picker reopens the wrong session and cannot reopen the active one","url":null,"state":"closed"},{"kind":"issue","number":62802,"title":"Bug: Dashboard Chat tab ignores profile switch — keep-alive PTY reattaches previous profile's process","url":null,"state":"closed"},{"kind":"issue","number":63701,"title":"**Web dashboard: clicking a previous session opens a blank new session (`/api/pty` `?attach=` reattach discards the `?resume=` spawn env)**","url":null,"state":"closed"},{"kind":"issue","number":64154,"title":"Dashboard Chat: keep-alive PTY token not rotated on profile/session switch — stale PTY reused","url":null,"state":"closed"},{"kind":"issue","number":64425,"title":"Dashboard sidebar session resume does not display conversation history (v0.18.x regression)","url":null,"state":"closed"},{"kind":"pr","number":16009,"title":"fix(dashboard): keep chat alive across sidebar navigation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d293e0051ef3002a2a9136ccd55a9e524a6fe704","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19088,"title":"fix(dashboard): stabilize embedded chat resume and scrollback","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21015,"title":"fix(dashboard): rebuild PTY channel when /chat?resume=<id> changes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a0758cd1e9dc9e263d3b79067cf2d4955f7d2894","number":0},{"kind":"pr","value":"#19088","number":19088}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":44600,"title":"[codex] Hide dashboard sidecar sessions from history","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a7983d5ad768551508667e8c708e13def7ee28ab","number":0},{"kind":"pr","value":"#49269","number":49269}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49269,"title":"fix(dashboard): hide sidecar sessions from history","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session_context.py","run_agent.py","tests/gateway/test_session_env.py","tests/run_agent/test_session_source.py","tests/test_dashboard_sidecar_close_on_disconnect.py","tests/test_tui_gateway_server.py","tui_gateway/server.py","web/src/components/ChatSidebar.tsx"]},{"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"]}]},{"kind":"pr","number":60745,"title":"fix(dashboard): scope chat attach tokens by session","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","tests/test_pty_keepalive_ws.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -15149,7 +15149,8 @@ async def pty_ws(ws: WebSocket) -> None:\n         return\n \n     # --- spawn PTY ------------------------------------------------------\n-    resume = ws.query_params.get(\"resume\") or None\n+    raw_resume = ws.query_params.get(\"resume\") or None\n+    resume = raw_resume\n     profile = ws.query_params.get(\"profile\") or None\n     channel = _channel_or_close_code(ws)\n     sidecar_url = _build_sidecar_url(channel) if channel else None","@@ -15192,6 +15193,12 @@ async def pty_ws(ws: WebSocket) -> None:\n \n \n     attach_token = [redacted:secret-assignment]\"attach\") or None\n+    registry_resume = raw_resume\n+    if raw_resume and env:\n+        registry_resume = env.get(\"HERMES_TUI_RESUME\") or raw_resume\n+    if attach_token is not None and (registry_resume or profile):\n+        # Key explicit resumes on their canonical target, never the active-session fallback.\n+        attach_token = f\"{attach_token}\\0{profile or ''}\\0{registry_resume or ''}\"\n \n     def _spawn():\n         return PtyBridge.spawn(argv, cwd=cwd, env=env)"]},{"file":"tests/test_pty_keepalive_ws.py","hunks":["@@ -1,53 +1,136 @@\n+import json\n+\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+class FakeBridge:\n+    def __init__(self):\n+        self.alive = True\n+\n+    def read(self, timeout):\n+        return b\"\"        # idle forever\n \n-    class FakeBridge:\n-        def __init__(self):\n-            self.alive = True\n+    def write(self, data):\n+        pass\n \n-        def read(self, timeout):\n-            return b\"\"        # idle forever\n+    def resize(self, cols, rows):\n+        pass\n \n-        def write(self, data):\n-            pass\n+    def close(self):\n+        self.alive = False\n \n-        def resize(self, cols, rows):\n-            pass\n \n-        def close(self):\n-            self.alive = False\n[redacted:email]\n+def pty_keepalive_harness(monkeypatch):\n+    spawned = []\n \n     def fake_spawn(argv, cwd=None, env=None):\n         b = FakeBridge()\n-        spawned.append(b)\n+        spawned.append(argv)\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+        resume = \"child\" if kw.get(\"resume\") == \"parent\" else kw.get(\"resume\")\n+        env = {\"HERMES_TUI_RESUME\": resume} if resume else {}\n+        return ([\"x\", resume or \"fresh\"], \"/tmp\", env)\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+        yield spawned\n     finally:\n         web_server.PTY_REGISTRY._sessions.clear()\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_same_session(pty_keepalive_harness):\n+    \"\"\"Two connects with the same ?attach= token hit one spawned bridge.\"\"\"\n+    from starlette.testclient import TestClient\n+\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(pty_keepalive_harness) == 1                # reattached, did not respawn\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_same_resume(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=same\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=same\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"same\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_does_not_reuse_different_resume(pty_keepalive_harness):\n+    \"\"\"A keep-alive token must not pin /chat to an old selected session.\"\"\"\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=old\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=new\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"old\"], [\"x\", \"new\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=parent\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=child\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"child\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_does_not_reuse_different_profile(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&profile=alpha\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&profile=beta\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert len(pty_keepalive_harness) == 2\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_default_chat_after_active_session_fallback(\n+    pty_keepalive_harness, tmp_path, monkeypatch\n+):\n+    from starlette.testclient import TestClient\n+\n+    active_session_file = tmp_path / \"active-session.json\"\n+    monkeypatch.setattr(\n+        web_server,\n+        \"_active_session_file_for_channel\",\n+        lambda app, channel: active_session_file,\n+    )\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&channel=CHAT\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+\n+    active_session_file.write_text(json.dumps({\"session_id\": \"existing\"}))\n+\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&channel=CHAT\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+\n+    assert pty_keepalive_harness == [[\"x\", \"fresh\"]]\n"]}]},{"kind":"pr","number":60878,"title":"Fix dashboard resumed session transcripts","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61290,"title":"fix(dashboard): key keep-alive PTY on session, not just tab token (#61284)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60745","number":60745},{"kind":"pr","value":"#61284","number":61284},{"kind":"pr","value":"#60745","number":60745}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":61313,"title":"fix(dashboard): force fresh PTY spawn when resume target changes","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#60745","number":60745},{"kind":"pr","value":"#19085","number":19085},{"kind":"pr","value":"#60745","number":60745}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61320,"title":"fix(dashboard): key keep-alive PTY on resume+profile, not just tab token (#61284)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":62178,"title":"fix(web): scope chat resume reattachments","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"636d9b3f6bce","number":0},{"kind":"pr","value":"#62045","number":62045}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62622,"title":"fix(61284): [Bug]: Dashboard chat sessions fail to render due to recent WebSocket re","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62813,"title":"fix(dashboard): force fresh PTY on profile switch in Chat tab","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#62581","number":62581},{"kind":"pr","value":"#62581","number":62581},{"kind":"pr","value":"#62581","number":62581},{"kind":"pr","value":"#62802","number":62802}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63731,"title":"fix(web): force PTY re-spawn on session resume to respect new resume target","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":64163,"title":"fix(dashboard): scope PTY keep-alive token to profile+resume","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60745","number":60745},{"kind":"pr","value":"#64154","number":64154}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":64444,"title":"fix(dashboard): force fresh PTY spawn when resume target changes","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#61313","number":61313},{"kind":"pr","value":"#62058","number":62058},{"kind":"pr","value":"#61107","number":61107},{"kind":"pr","value":"#63731","number":63731}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":133902,"title":"fix(dashboard): hydrate resumed session transcripts in the Chat resume path","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"fa9117aa86b6eb773fa825e1f5669431133b7f6b","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":133941,"title":"fix(desktop): a session-picker click re-selects the active session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/open-session.test.ts","apps/desktop/src/app/open-session.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}