# NousResearch/hermes-agent · Dashboard Chat keep-alive PTY reuses stale profile or session

## Open now

- 0 issues, 4 PRs open on GitHub.
- PR #62178: fix(web): scope chat resume reattachments
- PR #62622: fix(61284): \[Bug\]: Dashboard chat sessions fail to render due to recent WebSocket re
- PR #62813: fix(dashboard): force fresh PTY on profile switch in Chat tab
- PR #64444: fix(dashboard): force fresh PTY spawn when resume target changes

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.

## TL;DR

- 17 member(s): 0 open issue(s), 7 closed issue(s), 4 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #60745. Verify it addresses your case.
- **Recommended:** read PR #60745 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-60745,pr-133941`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.12`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #61284: \[Bug\]: Dashboard chat sessions fail to render due to recent WebSocket regression
- #61919: \[Bug\]: Dashboard Chat session switch keeps showing old conversation (keep-alive PTY attach token ignores new resume id)
- #62045: \[Bug\]: Dashboard chat session picker reopens the wrong session and cannot reopen the active one
- #62802: Bug: Dashboard Chat tab ignores profile switch — keep-alive PTY reattaches previous profile's process
- #63701: **Web dashboard: clicking a previous session opens a blank new session (\`/api/pty\` \`?attach=\` reattach discards the \`?resume=\` spawn env)**
- #64154: Dashboard Chat: keep-alive PTY token not rotated on profile/session switch — stale PTY reused
- #64425: Dashboard sidebar session resume does not display conversation history (v0.18.x regression)

## Prior & competing PRs

- **#60745: fix(dashboard): scope chat attach tokens by session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/web_server.py`, `tests/test_pty_keepalive_ws.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -15149,7 +15149,8 @@ async def pty_ws(ws: WebSocket) -> None:
             return
     
         # --- spawn PTY ------------------------------------------------------
    -    resume = ws.query_params.get("resume") or None
    +    raw_resume = ws.query_params.get("resume") or None
    +    resume = raw_resume
         profile = ws.query_params.get("profile") or None
         channel = _channel_or_close_code(ws)
         sidecar_url = _build_sidecar_url(channel) if channel else None
    ```

    ```diff
    @@ -15192,6 +15193,12 @@ async def pty_ws(ws: WebSocket) -> None:
     
     
         attach_token = ws.query_params.get("attach") or None
    +    registry_resume = raw_resume
    +    if raw_resume and env:
    +        registry_resume = env.get("HERMES_TUI_RESUME") or raw_resume
    +    if attach_token is not None and (registry_resume or profile):
    +        # Key explicit resumes on their canonical target, never the active-session fallback.
    +        attach_token = f"{attach_token}\0{profile or ''}\0{registry_resume or ''}"
     
         def _spawn():
             return PtyBridge.spawn(argv, cwd=cwd, env=env)
    ```
  - `tests/test_pty_keepalive_ws.py`

    ```diff
    @@ -1,53 +1,136 @@
    +import json
    +
     import pytest
     
     from hermes_cli import web_server
     
     
    [redacted:email]
    -async def test_attach_token_reuses_same_session(monkeypatch):
    -    """Two connects with the same ?attach= token hit one spawned bridge."""
    -    spawned = []
    +class FakeBridge:
    +    def __init__(self):
    +        self.alive = True
    +
    +    def read(self, timeout):
    +        return b""        # idle forever
     
    -    class FakeBridge:
    -        def __init__(self):
    -            self.alive = True
    +    def write(self, data):
    +        pass
     
    -        def read(self, timeout):
    -            return b""        # idle forever
    +    def resize(self, cols, rows):
    +        pass
     
    -        def write(self, data):
    -            pass
    +    def close(self):
    +        self.alive = False
     
    -        def resize(self, cols, rows):
    -            pass
     
    -        def close(self):
    -            self.alive = False
    [redacted:email]
    +def pty_keepalive_harness(monkeypatch):
    +    spawned = []
     
         def fake_spawn(argv, cwd=None, env=None):
             b = FakeBridge()
    -        spawned.append(b)
    +        spawned.append(argv)
             return b
     
         monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn))
    -    # bypass auth + argv resolution for the test
         monkeypatch.setattr(web_server, "_ws_auth_reason", lambda ws: (None, "test"))
         monkeypatch.setattr(web_server, "_ws_host_origin_reason", lambda ws: None)
         monkeypatch.setattr(web_server, "_ws_client_reason", lambda ws: None)
     
         async def fake_argv(**kw):
    -        return (["x"], "/tmp", {})
    +        resume = "child" if kw.get("resume") == "parent" else kw.get("resume")
    +        env = {"HERMES_TUI_RESUME": resume} if resume else {}
    +        return (["x", resume or "fresh"], "/tmp", env)
     
         monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_argv)
     
    -    from starlette.testclient import TestClient
    -
         try:
    -        client = TestClient(web_server.app)
    -        with client.websocket_connect("/api/pty?attach=TOK1") as ws1:
    -            ws1.send_bytes(b"hi")
    -        with client.websocket_connect("/api/pty?attach=TOK1") as ws2:
    -            ws2.send_bytes(b"again")
    -        assert len(spawned) == 1                # reattached, did not respawn
    +        yield spawned
         finally:
             web_server.PTY_REGISTRY._sessions.clear()
    +
    +
    [redacted:email]
    +async def test_attach_token_reuses_same_session(pty_keepalive_harness):
    +    """Two connects with the same ?attach= token hit one spawned bridge."""
    +    from starlette.testclient import TestClient
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1") as ws1:
    +        ws1.send_bytes(b"hi")
    +    with client.websocket_connect("/api/pty?attach=TOK1") as ws2:
    +        ws2.send_bytes(b"again")
    +    assert len(pty_keepalive_harness) == 1                # reattached, did not respawn
    +
    +
    [redacted:email]
    +async def test_attach_token_reuses_same_resume(pty_keepalive_harness):
    +    from starlette.testclient import TestClient
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws1:
    +        ws1.send_bytes(b"hi")
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=same") as ws2:
    +        ws2.send_bytes(b"again")
    +    assert pty_keepalive_harness == [["x", "same"]]
    +
    +
    [redacted:email]
    +async def test_attach_token_does_not_reuse_different_resume(pty_keepalive_harness):
    +    """A keep-alive token must not pin /chat to an old selected session."""
    +    from starlette.testclient import TestClient
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=old") as ws1:
    +        ws1.send_bytes(b"hi")
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=new") as ws2:
    +        ws2.send_bytes(b"again")
    +    assert pty_keepalive_harness == [["x", "old"], ["x", "new"]]
    +
    +
    [redacted:email]
    +async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness):
    +    from starlette.testclient import TestClient
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=parent") as ws1:
    +        ws1.send_bytes(b"hi")
    +    with client.websocket_connect("/api/pty?attach=TOK1&resume=child") as ws2:
    +        ws2.send_bytes(b"again")
    +    assert pty_keepalive_harness == [["x", "child"]]
    +
    +
    [redacted:email]
    +async def test_attach_token_does_not_reuse_different_profile(pty_keepalive_harness):
    +    from starlette.testclient import TestClient
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1&profile=alpha") as ws1:
    +        ws1.send_bytes(b"hi")
    +    with client.websocket_connect("/api/pty?attach=TOK1&profile=beta") as ws2:
    +        ws2.send_bytes(b"again")
    +    assert len(pty_keepalive_harness) == 2
    +
    +
    [redacted:email]
    +async def test_attach_token_reuses_default_chat_after_active_session_fallback(
    +    pty_keepalive_harness, tmp_path, monkeypatch
    +):
    +    from starlette.testclient import TestClient
    +
    +    active_session_file = tmp_path / "active-session.json"
    +    monkeypatch.setattr(
    +        web_server,
    +        "_active_session_file_for_channel",
    +        lambda app, channel: active_session_file,
    +    )
    +
    +    client = TestClient(web_server.app)
    +    with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws1:
    +        ws1.send_bytes(b"hi")
    +
    +    active_session_file.write_text(json.dumps({"session_id": "existing"}))
    +
    +    with client.websocket_connect("/api/pty?attach=TOK1&channel=CHAT") as ws2:
    +        ws2.send_bytes(b"again")
    +
    +    assert pty_keepalive_harness == [["x", "fresh"]]
    ```
- **#63731: fix(web): force PTY re-spawn on session resume to respect new resume target**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#133941: fix(desktop): a session-picker click re-selects the active session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/app/open-session.test.ts`, `apps/desktop/src/app/open-session.ts`
- **#61290: fix(dashboard): key keep-alive PTY on session, not just tab token (#61284)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#60745`, `#61284`
- **#61320: fix(dashboard): key keep-alive PTY on resume+profile, not just tab token (#61284)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#62178: fix(web): scope chat resume reattachments**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `636d9b3f6bce`, `#62045`
  - files: `web/src/lib/chat-resume.test.ts`, `web/src/lib/chat-resume.ts`, `web/src/pages/ChatPage.tsx`, `web/src/pages/SessionsPage.tsx`
- **#62622: fix(61284): \[Bug\]: Dashboard chat sessions fail to render due to recent WebSocket re**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `ui-tui/packages/hermes-ink/src/ink/terminal.ts`
- **#62813: fix(dashboard): force fresh PTY on profile switch in Chat tab**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#62581`, `#62802`
  - files: `web/src/pages/ChatPage.tsx`
- **#64163: fix(dashboard): scope PTY keep-alive token to profile+resume**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#60745`, `#64154`
- **#64444: fix(dashboard): force fresh PTY spawn when resume target changes**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#61313`, `#62058`, `#61107`, `#63731`
  - files: `hermes_cli/pty_session.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_web_server.py`, `tests/test_pty_keepalive_ws.py`, `tests/test_pty_session.py`, `web/src/pages/ChatPage.tsx`

## Fix status

- Merged to main in PR #60745 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-10-06`.
- Subsystem: `dashboard`
