{"repo":"NousResearch/hermes-agent","public_id":"61284","display_label":"Dashboard Chat keep-alive PTY reuses stale profile or session","summary":"Most members describe the Dashboard Chat tab reattaching to a previously kept-alive PTY when the profile or resume/session target changes, with reports of blank, wrong, or old conversations and several PRs proposing to scope the attach token by profile and resume. A few members (WebSocket rendering regression, session-picker re-selecting the active session) may be related but are less clearly the same cause.","subsystem":"dashboard","counts":{"members":17,"open_issues":0,"open_issues_distinct":0,"open_prs":4,"closed_issues":7,"merged_prs":2,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60745,"merged_at":"2026-07-16T23:48:48+00:00"},"resolution":{"state":"partially_resolved","by":"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.12205882352941175},"fix_edges":[{"issue_number":61284,"pr_number":60745,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T23:48:48+00:00"},{"issue_number":62045,"pr_number":133941,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-10-06T16:24:09+00:00"},{"issue_number":64154,"pr_number":60745,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-16T23:48:48+00:00"}],"members":[{"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":60745,"title":"fix(dashboard): scope chat attach tokens by session","url":null,"state":"merged","close_reason":{"category":"merged"},"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 = ws.query_params.get(\"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":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}],"category":"duplicate_of","implication":"duplicate"},"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":"#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":133941,"title":"fix(desktop): a session-picker click re-selects the active session","url":null,"state":"merged","close_reason":{"category":"merged"},"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}}