{"repo":"NousResearch/hermes-agent","public_id":"50932","display_label":"Desktop sessions incorrectly tagged as TUI source","summary":"A bug where desktop app chat sessions were being labeled with a 'tui' source instead of 'desktop', causing incorrect terminal guidance. Multiple fix attempts and PRs address adding the correct source tag to session create/resume calls.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60881},"resolution":{"state":"partially_resolved","by":"pr-60881"},"freshness":{"state":"verified","last_verified_at":"2026-07-08T13:18:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":58404,"pr_number":60881,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":50932,"title":"bug: Desktop app sessions saved as source \"tui\" instead of \"desktop\"","url":null,"state":"closed"},{"kind":"issue","number":58404,"title":"[Bug]: Desktop chat sessions are tagged as TUI and receive terminal guidance","url":null,"state":"closed"},{"kind":"pr","number":50962,"title":"fix(desktop): add source='desktop' to session.create/resume calls","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ac6dd598a4b8a76c044f5b4cde4ca354845c9f45","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51090,"title":"fix(desktop): mark desktop sessions with desktop source","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ac6dd598a4b8a76c044f5b4cde4ca354845c9f45","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":58405,"title":"fix(desktop): tag chat sessions as desktop, not tui","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ac6dd598a","number":0},{"kind":"pr","value":"#60881","number":60881}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60881,"title":"fix(desktop): tag chat sessions as desktop, not tui (salvage #58405)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","agent/system_prompt.py","apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","apps/desktop/src/app/session/hooks/use-session-actions/index.ts","tests/agent/test_platform_hint_desktop.py","tests/test_tui_gateway_server.py","tests/tui_gateway/test_protocol.py","tests/tui_gateway/test_session_platform_resolution.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/prompt_builder.py","hunks":["@@ -743,6 +743,17 @@ def format_steer_marker(steer_text: str) -> str:\n         \"or 'all'). Do not promise the user that a deliver='origin' or \"\n         \"default-deliver cron job will message them in this session.\"\n     ),\n+    \"desktop\": (\n+        \"You are chatting inside the Hermes desktop app — a graphical chat \"\n+        \"surface, not a terminal. Use markdown freely: it renders with full \"\n+        \"GitHub flavor (tables, code blocks with syntax highlighting, math \"\n+        \"via $...$, task lists, blockquote callouts). \"\n+        \"You can deliver files natively — include MEDIA:/absolute/path/to/file \"\n+        \"in your response. Images (.png, .jpg, .webp) appear inline, audio and \"\n+        \"video play inline, and other files arrive as download links. You can \"\n+        \"also include image URLs in markdown format ![alt](url) and they \"\n+        \"render inline as photos.\"\n+    ),\n     \"sms\": (\n         \"You are communicating via SMS. Keep responses concise and use plain text \"\n         \"only — no markdown, no formatting. SMS messages are limited to ~1600 \"","@@ -1127,22 +1138,6 @@ def build_environment_hints() -> str:\n                 f\"`uname -a && whoami && pwd`.\"\n             )\n \n-    # Hermes desktop GUI — any agent running under the desktop app should know\n-    # it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL\n-    # marks a hermes launched in the embedded terminal pane. Both set by main.cjs.\n-    _truthy = (\"1\", \"true\", \"yes\")\n-    _in_desktop = (os.getenv(\"HERMES_DESKTOP\") or \"\").strip().lower() in _truthy\n-    _in_desktop_term = (os.getenv(\"HERMES_DESKTOP_TERMINAL\") or \"\").strip().lower() in _truthy\n-    if _in_desktop or _in_desktop_term:\n-        _desktop_hint = \"Runtime surface: you're running inside the Hermes desktop GUI app.\"\n-        if _in_desktop_term:\n-            _desktop_hint += (\n-                \" You're in its embedded terminal pane, beside the GUI chat — the user can \"\n-                \"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press \"\n-                \"⌘/Ctrl+L to send it to the chat composer.\"\n-            )\n-        hints.append(_desktop_hint)\n-\n     if is_wsl():\n         hints.append(WSL_ENVIRONMENT_HINT)\n "]},{"file":"agent/system_prompt.py","hunks":["@@ -24,6 +24,7 @@\n from __future__ import annotations\n \n import json\n+import os\n from typing import Any, Dict, List, Optional\n \n from agent.prompt_builder import (","@@ -44,6 +45,7 @@\n     drain_truncation_warnings,\n )\n from agent.runtime_cwd import resolve_context_cwd\n+from utils import is_truthy_value\n \n \n def _ra():","@@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->\n     return base\n \n \n+_TUI_EMBEDDED_PANE_CLARIFIER = (\n+    \" You're in its embedded terminal pane, beside the GUI chat — the user can \"\n+    \"select your output (Option-drag on macOS, Shift-drag elsewhere) and press \"\n+    \"Cmd/Ctrl+L to send it to the chat composer.\"\n+)\n+\n+\n+def _tui_embedded_pane_clarifier(hint: str) -> str:\n+    \"\"\"Append the desktop-embedded-terminal-pane clarifier to a tui hint.\n+\n+    Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the\n+    shell env of the desktop's embedded TUI PTY — never on the chat backend).\n+    This is a runtime-surface qualifier, not a config override, so it lives at\n+    the resolution site rather than inside ``_resolve_platform_hint`` (which\n+    is purely the config-platform_hints override applier). Byte-stable for the\n+    cache: called once per session build, deterministically from env state.\n+\n+    Idempotent and empty-safe: re-applying on an already-augmented hint is a\n+    no-op, and an empty input returns empty (we never synthesize the\n+    clarifier without its tui framing).\n+    \"\"\"\n+    if not hint:\n+        return hint\n+    if _TUI_EMBEDDED_PANE_CLARIFIER in hint:\n+        return hint\n+    if not is_truthy_value(os.getenv(\"HERMES_DESKTOP_TERMINAL\")):\n+        return hint\n+    return hint + _TUI_EMBEDDED_PANE_CLARIFIER\n+\n+\n def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:\n     \"\"\"Assemble the system prompt as three ordered parts.\n ","@@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)\n             pass\n \n     _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)\n+    if platform_key == \"tui\" and _effective_hint:\n+        _effective_hint = _tui_embedded_pane_clarifier(_effective_hint)\n     if _effective_hint:\n         stable_parts.append(_effective_hint)\n "]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","hunks":["@@ -1084,7 +1084,7 @@ describe('usePromptActions sleep/wake session recovery', () => {\n     expect(ok).toBe(true)\n     // First submit (stale id) → session.resume (stored id) → retry submit (fresh id).\n     expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])\n-    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })\n+    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })\n     expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })\n   })\n ","@@ -1127,7 +1127,7 @@ describe('usePromptActions sleep/wake session recovery', () => {\n \n     expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])\n     expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })\n-    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })\n+    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })\n     expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })\n   })\n "]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","hunks":["@@ -546,7 +546,8 @@ export function usePromptActions({\n       if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {\n         try {\n           const resumed = await requestGateway<{ session_id: string }>('session.resume', {\n-            session_id: selectedStoredSessionIdRef.current\n+            session_id: selectedStoredSessionIdRef.current,\n+            source: 'desktop'\n           })\n \n           const recoveredId = resumed?.session_id"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","hunks":["@@ -260,7 +260,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n           if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {\n             // Re-register the session in the gateway and get a fresh live ID.\n             const resumed = await requestGateway<{ session_id: string }>('session.resume', {\n-              session_id: selectedStoredSessionIdRef.current\n+              session_id: selectedStoredSessionIdRef.current,\n+              source: 'desktop'\n             })\n \n             const recoveredId = resumed?.session_id"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","hunks":["@@ -148,6 +148,12 @@ describe('createBackendSessionForSend profile routing', () => {\n     expect(params).toMatchObject({ profile: 'default' })\n   })\n \n+  it('tags new desktop chats as desktop sessions', async () => {\n+    const params = await createWith(() => {})\n+\n+    expect(params).toMatchObject({ source: 'desktop' })\n+  })\n+\n   it('passes the current workspace cwd into session.create', async () => {\n     const params = await createWith(() => {\n       $currentCwd.set('/remote/worktree')","@@ -328,6 +334,7 @@ describe('resumeSession failure recovery', () => {\n \n     expect(resumeParams).not.toHaveProperty('lazy')\n     expect(resumeParams).not.toHaveProperty('eager_build')\n+    expect(resumeParams).toMatchObject({ source: 'desktop' })\n   })\n \n   it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {","@@ -412,6 +419,78 @@ describe('resumeSession failure recovery', () => {\n   })\n })\n \n+function BranchHarness({\n+  onReady,\n+  requestGateway\n+}: {\n+  onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void\n+  requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>\n+}) {\n+  const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })\n+\n+  const actions = useSessionActions({\n+    activeSessionId: null,\n+    activeSessionIdRef: ref<string | null>(null),\n+    busyRef: ref(false),\n+    creatingSessionRef: ref(false),\n+    ensureSessionState: () => ({}) as ClientSessionState,\n+    getRouteToken: () => 'token',\n+    navigate: vi.fn() as never,\n+    requestGateway,\n+    runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),\n+    selectedStoredSessionId: null,\n+    selectedStoredSessionIdRef: ref<string | null>(null),\n+    sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),\n+    syncSessionStateToView: vi.fn(),\n+    updateSessionState: () => ({}) as ClientSessionState\n+  })\n+\n+  useEffect(() => {\n+    onReady(actions.branchStoredSession)\n+  }, [actions.branchStoredSession, onReady])\n+\n+  return null\n+}\n+\n+describe('branchStoredSession desktop source tagging', () => {\n+  afterEach(() => {\n+    cleanup()\n+    setSessions([])\n+    vi.restoreAllMocks()\n+  })\n+\n+  it('tags desktop branch sessions as desktop sessions', async () => {\n+    let createParams: Record<string, unknown> | undefined\n+\n+    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {\n+      if (method === 'session.create') {\n+        createParams = params\n+\n+        return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never\n+      }\n+\n+      return {} as never\n+    })\n+\n+    setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])\n+    vi.mocked(getSessionMessages).mockResolvedValue({\n+      messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],\n+      session_id: 'stored-parent'\n+    } as never)\n+\n+    let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null\n+    render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)\n+    await waitFor(() => expect(branchStoredSession).not.toBeNull())\n+\n+    await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)\n+\n+    expect(createParams).toMatchObject({\n+      parent_session_id: 'stored-parent',\n+      source: 'desktop'\n+    })\n+  })\n+})\n+\n // ── Warm-cache mapping integrity (the \"open chat A, chat B loads\" bug) ─────────\n // resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached\n // state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions/index.ts","hunks":["@@ -176,6 +176,7 @@ export function useSessionActions({\n \n         const created = await requestGateway<SessionCreateResponse>('session.create', {\n           cols: 96,\n+          source: 'desktop',\n           ...(cwd && { cwd }),\n           ...(newChatProfile ? { profile: newChatProfile } : {}),\n           ...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),","@@ -460,6 +461,7 @@ export function useSessionActions({\n         const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {\n           session_id: storedSessionId,\n           cols: 96,\n+          source: 'desktop',\n           // Watch windows attach lazily (live mirror). Every other cold resume\n           // gets the gateway's default deferred build: the RPC returns the\n           // transcript immediately instead of blocking the switch on _make_agent","@@ -642,6 +644,7 @@ export function useSessionActions({\n         // No title: the backend auto-names the branch from its parent's lineage.\n         const branched = await requestGateway<SessionCreateResponse>('session.create', {\n           cols: 96,\n+          source: 'desktop',\n           ...(cwd && { cwd }),\n           messages: branchMessages.map(({ content, role }) => ({ content, role })),\n           ...(parentStoredId && { parent_session_id: parentStoredId })"]},{"file":"tests/agent/test_platform_hint_desktop.py","hunks":["@@ -0,0 +1,246 @@\n+\"\"\"System-prompt assembly for the desktop chat surface.\n+\n+Pins the second half of the fix: the new ``PLATFORM_HINTS[\"desktop\"]``\n+entry, the deletion of the standalone desktop-hint block from\n+``build_environment_hints()``, and the lookup-site extension that\n+appends the embedded-terminal-pane clarifier to the ``tui`` platform hint\n+when ``HERMES_DESKTOP_TERMINAL=1``.\n+\n+These tests run against the real prompt builders (no mocks) because\n+cache-stability and byte-for-byte text contracts are what we are\n+verifying — mocking the resolver would hide exactly the class of bug\n+this test covers.\n+\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from agent.prompt_builder import PLATFORM_HINTS, build_environment_hints\n+from agent.system_prompt import (\n+    _tui_embedded_pane_clarifier,\n+    build_system_prompt_parts,\n+)\n+\n+\n+def _stable_prompt(agent):\n+    with (\n+        patch(\"run_agent.load_soul_md\", return_value=\"\"),\n+        patch(\"run_agent.build_nous_subscription_prompt\", return_value=\"\"),\n+        patch(\"run_agent.build_environment_hints\", return_value=\"\"),\n+        patch(\"run_agent.build_context_files_prompt\", return_value=\"\"),\n+    ):\n+        return build_system_prompt_parts(agent)[\"stable\"]\n+\n+\n+def _make_agent(platform=\"\", **overrides):\n+    base = dict(\n+        load_soul_identity=False,\n+        skip_context_files=False,\n+        valid_tool_names=[],\n+        _task_completion_guidance=False,\n+        _tool_use_enforcement=False,\n+        _environment_probe=False,\n+        _kanban_worker_guidance=\"\",\n+        _memory_store=None,\n+        _memory_manager=None,\n+        _platform_hint_overrides={},\n+        model=\"\",\n+        provider=\"\",\n+        pass_session_id=False,\n+        session_id=\"\",\n+    )\n+    base[\"platform\"] = platform\n+    base.update(overrides)\n+    return SimpleNamespace(**base)\n+\n+\n+class TestDesktopHintEntry:\n+    def test_desktop_key_exists(self):\n+        \"\"\"The map must carry a \"desktop\" entry — without it the platform\n+        hint lookup falls through to an empty string and the agent gets no\n+        surface framing at all on the desktop chat surface.\"\"\"\n+        assert \"desktop\" in PLATFORM_HINTS\n+\n+    def test_desktop_hint_disambiguates_from_terminal(self):\n+        \"\"\"The agent must be told it is in a graphical chat surface, NOT a\n+        terminal. This is the line that kills the contradiction with the\n+        old tui mis-tag.\"\"\"\n+        hint = PLATFORM_HINTS[\"desktop\"]\n+        lowered = hint.lower()\n+        assert \"desktop\" in lowered\n+        assert \"not a terminal\" in lowered\n+        assert \"graphical chat surface\" in lowered\n+\n+    def test_desktop_hint_advertises_markdown(self):\n+        \"\"\"The desktop renderer supports full GFM (verified via the\n+        Streamdown pipeline in apps/desktop). The hint must steer the\n+        agent toward markdown, not away from it like the cli/tui hints do.\"\"\"\n+        hint = PLATFORM_HINTS[\"desktop\"]\n+        assert \"markdown\" in hint.lower()\n+\n+    def test_desktop_hint_advertises_media_delivery(self):\n+        \"\"\"The desktop chat intercepts MEDIA:/abs/path like telegram — images\n+        inline, audio/video inline players, other files as download links.\n+        Without this line the agent falls back to the cli/tui \"state the\n+        path in text\" model, which is the wrong UX for the desktop surface.\"\"\"\n+        hint = PLATFORM_HINTS[\"desktop\"]\n+        assert \"MEDIA:\" in hint\n+\n+    def test_desktop_hint_advertises_inline_image_urls(self):\n+        hint = PLATFORM_HINTS[\"desktop\"]\n+        assert \"![alt](url)\" in hint\n+\n+    def test_desktop_hint_does_not_inherit_tui_cron_local_only_block(self):\n+        \"\"\"The desktop chat surface's cron delivery semantics differ from\n+        the standalone TUI — desktop runs its own cron ticker in-process\n+        (hermes_cli/web_server.py under HERMES_DESKTOP=1). We deliberately\n+        do NOT parrot the tui \"LOCAL-ONLY … no live-delivery channel\" block\n+        into the desktop hint, since partially-correct cron guidance is\n+        exactly the bug class we are fixing. Cron guidance for desktop is\n+        deferred to a follow-up issue.\"\"\"\n+        hint = PLATFORM_HINTS[\"desktop\"]\n+        assert \"LOCAL-ONLY\" not in hint\n+\n+\n+class TestDesktopHintBlockRemoved:\n+    \"\"\"The standalone desktop-hint block that used to live in\n+    ``build_environment_hints()`` (lines ~1130-1144) was a band-aid for the\n+    missing ``PLATFORM_HINTS[\"desktop\"]`` entry. Once that entry exists, the\n+    block is dead code that competes with the platform hint's claim of\n+    what surface the agent is on. It must be gone.\"\"\"\n+\n+    def test_build_environment_hints_has_no_runtime_surface_line(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+        from agent.prompt_builder import _clear_backend_probe_cache\n+        _clear_backend_probe_cache()\n+        hints = build_environment_hints()\n+        assert \"Runtime surface:\" not in hints\n+        assert \"desktop GUI app\" not in hints\n+\n+    def test_build_environment_hints_has_no_embedded_pane_clarifier(self, monkeypatch):\n+        \"\"\"The ⌥-drag / ⌘+L embedded-pane clarifier moves to the platform-hint\n+        resolution site (system_prompt.py), not build_environment_hints().\"\"\"\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        from agent.prompt_builder import _clear_backend_probe_cache\n+        _clear_backend_probe_cache()\n+        hints = build_environment_hints()\n+        assert \"embedded terminal pane\" not in hints\n+        assert \"Shift-drag\" not in hints\n+\n+\n+class TestPlatformHintResolutionInStablePrompt:\n+    \"\"\"End-to-end through ``build_system_prompt_parts`` — the platform tag on\n+    the agent drives BOTH which PLATFORM_HINTS entry gets appended AND\n+    whether the embedded-pane clarifier follows it. The desktop-hint block\n+    that used to live in ``build_environment_hints()`` is gone.\"\"\"\n+\n+    def test_desktop_platform_yields_desktop_hint_no_tui_framing(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+        stable = _stable_prompt(_make_agent(platform=\"desktop\"))\n+        assert PLATFORM_HINTS[\"desktop\"] in stable\n+        assert \"terminal UI\" not in stable\n+        assert \"Runtime surface:\" not in stable\n+        assert \"embedded terminal pane\" not in stable\n+\n+    def test_standalone_tui_yields_plain_tui_hint_no_clarifier(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_DESKTOP\", raising=False)\n+        monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+        stable = _stable_prompt(_make_agent(platform=\"tui\"))\n+        assert PLATFORM_HINTS[\"tui\"] in stable\n+        assert \"embedded terminal pane\" not in stable\n+\n+    def test_embedded_tui_yields_tui_hint_with_clarifier(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        stable = _stable_prompt(_make_agent(platform=\"tui\"))\n+        assert PLATFORM_HINTS[\"tui\"] in stable\n+        assert \"embedded terminal pane\" in stable\n+        assert \"Shift-drag\" in stable or \"Option-drag\" in stable or \"⌥\" in stable\n+\n+    def test_embedded_clarifier_does_not_attach_to_desktop_platform(self, monkeypatch):\n+        \"\"\"Critical regression: even when HERMES_DESKTOP_TERMINAL=1, a\n+        desktop-tagged session must NOT get the embedded-pane clarifier —\n+        the clarifier describes the *embedded terminal pane*, which a\n+        desktop chat session is not.\"\"\"\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        stable = _stable_prompt(_make_agent(platform=\"desktop\"))\n+        assert \"embedded terminal pane\" not in stable\n+\n+\n+class TestEmbeddedTuiPaneClarifier:\n+    \"\"\"When ``HERMES_DESKTOP_TERMINAL=1``, a standalone ``hermes --tui`` is\n+    running inside the desktop's embedded terminal pane. The user can\n+    ⌥-drag-select its output and ⌘/Ctrl+L to send it to the chat composer.\n+    That clarifier must be appended to the ``tui`` platform hint at the\n+    resolution site, NOT baked into the static ``PLATFORM_HINTS[\"tui\"]``\n+    string (which is shared with every standalone TUI session and must\n+    stay byte-stable).\"\"\"\n+\n+    def test_tui_standalone_hint_byte_stable_without_env(self, monkeypatch):\n+        \"\"\"Without HERMES_DESKTOP_TERMINAL, the clarifier is a no-op and the\n+        resolved tui hint is exactly the static PLATFORM_HINTS[\"tui\"]\n+        string. Cache-stable for every standalone TUI session.\"\"\"\n+        monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+        out = _tui_embedded_pane_clarifier(PLATFORM_HINTS[\"tui\"])\n+        assert out == PLATFORM_HINTS[\"tui\"]\n+\n+    def test_embedded_pane_clarifier_appended_when_env_set(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        out = _tui_embedded_pane_clarifier(PLATFORM_HINTS[\"tui\"])\n+        assert out.startswith(PLATFORM_HINTS[\"tui\"])\n+        assert \"embedded terminal pane\" in out\n+        assert \"Shift-drag\" in out or \"Option-drag\" in out or \"⌥\" in out\n+\n+    def test_embedded_pane_clarifier_idempotent(self, monkeypatch):\n+        \"\"\"Calling the clarifier twice must NOT double-append the sentence.\n+        Cache-stability: the resolver is called once per session build, so\n+        re-applying on an already-augmented hint is a no-op.\"\"\"\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        once = _tui_embedded_pane_clarifier(PLATFORM_HINTS[\"tui\"])\n+        twice = _tui_embedded_pane_clarifier(once)\n+        assert once == twice\n+\n+    def test_embedded_pane_clarifier_does_not_touch_empty_hint(self, monkeypatch):\n+        \"\"\"Defensive: if the tui hint is somehow empty (e.g. overridden to\n+        empty by config), do not synthesize a clarifier-only hint — that\n+        would put a desktop-pane reference in the prompt without the tui\n+        surface framing it sits under.\"\"\"\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        out = _tui_embedded_pane_clarifier(\"\")\n+        assert out == \"\"\n+\n+    @pytest.mark.parametrize(\"val\", [\"0\", \"false\", \"no\", \"\", \"0\", \"False\"])\n+    def test_falsy_env_does_not_trigger_clarifier(self, monkeypatch, val):\n+        monkeypatch.setenv(\"HERMES_DESKTOP_TERMINAL\", val)\n+        out = _tui_embedded_pane_clarifier(PLATFORM_HINTS[\"tui\"])\n+        assert out == PLATFORM_HINTS[\"tui\"], (\n+            f\"HERMES_DESKTOP_TERMINAL={val!r} should not trigger clarifier\"\n+        )\n+\n+\n+class TestContradictionGone:\n+    \"\"\"The original contradiction: a single assembled system prompt\n+    contained both ``You are running in the Hermes terminal UI (TUI).`` and\n+    ``Runtime surface: you're running inside the Hermes desktop GUI app.``.\n+    After the fix, no single session's prompt can carry both.\"\"\"\n+\n+    def test_desktop_chat_session_has_no_tui_framing(self, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_DESKTOP\", \"1\")\n+        monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+        assert \"tui\" in PLATFORM_HINTS\n+        assert \"desktop\" in PLATFORM_HINTS\n+        desktop_hint = PLATFORM_HINTS[\"desktop\"]\n+        tui_hint = PLATFORM_HINTS[\"tui\"]\n+        assert \"terminal UI\" not in desktop_hint\n+        assert \"terminal UI\" in tui_hint\n+\n+    def test_tui_hint_does_not_carry_desktop_marker(self):\n+        tui_hint = PLATFORM_HINTS[\"tui\"]\n+        assert \"desktop GUI app\" not in tui_hint\n+        assert \"Runtime surface:\" not in tui_hint"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -13,6 +13,7 @@\n \n from hermes_constants import reset_hermes_home_override, set_hermes_home_override\n from hermes_cli.active_sessions import active_session_registry_snapshot\n+from hermes_cli.browser_connect import ChromeDebugLaunch\n from tui_gateway import server\n \n ","@@ -1827,7 +1828,7 @@ def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch):\n     monkeypatch.setattr(\n         server,\n         \"_notify_session_boundary\",\n-        lambda event, session_id: calls[\"hooks\"].append((event, session_id)),\n+        lambda event, session_id, *_args: calls[\"hooks\"].append((event, session_id)),\n     )\n \n     try:","@@ -1959,7 +1960,7 @@ def close(self):\n     monkeypatch.setattr(\n         server,\n         \"_notify_session_boundary\",\n-        lambda event, session_id: hooks.append((event, session_id)),\n+        lambda event, session_id, *_args: hooks.append((event, session_id)),\n     )\n \n     import tools.approval as _approval","@@ -5498,7 +5499,7 @@ def __init__(self):\n     release_build = threading.Event()\n     build_entered = threading.Event()\n \n-    def _slow_make_agent(sid, key, session_id=None, session_db=None):\n+    def _slow_make_agent(sid, key, session_id=None, session_db=None, **_kwargs):\n         build_started.set()\n         build_entered.set()\n         release_build.wait(timeout=3.0)","@@ -5606,7 +5607,7 @@ def __init__(self):\n             self.base_url = \"\"\n             self.api_key = \"\"\n \n-    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_db=None: _FakeAgent())\n+    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_db=None, **_kwargs: _FakeAgent())\n     monkeypatch.setattr(server, \"_SlashWorker\", _FakeWorker)\n     monkeypatch.setattr(\n         server,","@@ -5712,7 +5713,7 @@ def __init__(self):\n \n     emits = []\n \n-    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_db=None: _FakeAgent())\n+    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_db=None, **_kwargs: _FakeAgent())\n     monkeypatch.setattr(server, \"_SlashWorker\", _FakeWorker)\n     monkeypatch.setattr(server, \"_get_db\", lambda: None)\n     monkeypatch.setattr(server, \"_session_info\", lambda _a, *a2: {\"model\": \"x\"})","@@ -6810,8 +6811,10 @@ def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch):\n         _stub_urlopen(monkeypatch, ok=False)\n         with (\n             patch(\n-                \"hermes_cli.browser_connect.try_launch_chrome_debug\", return_value=False\n+                \"hermes_cli.browser_connect.launch_chrome_debug\",\n+                return_value=ChromeDebugLaunch(),\n             ),\n+            patch(\"hermes_cli.browser_connect.manual_chrome_debug_command\", return_value=None),\n             patch(\n                 \"hermes_cli.browser_connect.get_chrome_debug_candidates\",\n                 return_value=[],","@@ -6866,8 +6869,10 @@ def test_browser_manage_connect_no_session_skips_progress_events(monkeypatch):\n         _stub_urlopen(monkeypatch, ok=False)\n         with (\n             patch(\n-                \"hermes_cli.browser_connect.try_launch_chrome_debug\", return_value=False\n+                \"hermes_cli.browser_connect.launch_chrome_debug\",\n+                return_value=ChromeDebugLaunch(),\n             ),\n+            patch(\"hermes_cli.browser_connect.manual_chrome_debug_command\", return_value=None),\n             patch(\n                 \"hermes_cli.browser_connect.get_chrome_debug_candidates\",\n                 return_value=[],"]},{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -315,7 +315,7 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):\n             ]\n \n     monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n-    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_id=None, session_db=None: object())\n+    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_id=None, session_db=None, **_kwargs: object())\n     monkeypatch.setattr(server, \"_init_session\", lambda sid, key, agent, history, cols=80, **_kwargs: None)\n     monkeypatch.setattr(server, \"_session_info\", lambda _agent, _session=None: {\"model\": \"test/model\"})\n ","@@ -509,7 +509,7 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):\n             return [multimodal_user, text_only_assistant]\n \n     monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n-    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_id=None, session_db=None: object())\n+    monkeypatch.setattr(server, \"_make_agent\", lambda sid, key, session_id=None, session_db=None, **_kwargs: object())\n     monkeypatch.setattr(server, \"_init_session\", lambda sid, key, agent, history, cols=80, **_kwargs: None)\n     monkeypatch.setattr(server, \"_session_info\", lambda _agent, _session=None: {\"model\": \"test/model\"})\n ","@@ -795,7 +795,7 @@ def __init__(self, sid, session_id):\n         def close(self):\n             closed_sids.append(self.sid)\n \n-    def make_agent(sid, key, session_id=None, session_db=None):\n+    def make_agent(sid, key, session_id=None, session_db=None, **_kwargs):\n         created_sids.append(sid)\n         first_agent_started.set()\n         assert agent_can_finish.wait(timeout=1)","@@ -1006,7 +1006,7 @@ def close(self):\n     monkeypatch.setattr(\n         server,\n         \"_make_agent\",\n-        lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace(\n+        lambda _sid, key, session_id=None, session_db=None, **_kwargs: types.SimpleNamespace(\n             model=\"test/model\", session_id=session_id or key\n         ),\n     )","@@ -1144,7 +1144,7 @@ def set_session_title(self, _key, _title):\n     monkeypatch.setattr(\n         server,\n         \"_make_agent\",\n-        lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace(\n+        lambda _sid, key, session_id=None, session_db=None, **_kwargs: types.SimpleNamespace(\n             model=\"test/model\", session_id=session_id or key\n         ),\n     )"]},{"file":"tests/tui_gateway/test_session_platform_resolution.py","hunks":["@@ -0,0 +1,147 @@\n+\"\"\"Platform/source tagging for the desktop chat surface.\n+\n+The desktop app's chat panel uses ``hermes serve`` (the ``tui_gateway``\n+backend), so every chat session historically got ``platform=\"tui\"`` stamped\n+on it — even though the user is in a graphical chat surface, not a\n+terminal. That mis-tag is why the agent suggested TUI-only slash commands\n+(like ``/reload-mcp``) to desktop chat users.\n+\n+These tests pin the env-var matrix that resolves the session platform at\n+``tui_gateway`` session-creation time:\n+\n+  HERMES_DESKTOP=1, HERMES_DESKTOP_TERMINAL unset  -> platform=\"desktop\"\n+  HERMES_DESKTOP=1, HERMES_DESKTOP_TERMINAL=1     -> platform=\"tui\"  (embedded pane)\n+  neither set                                      -> platform=\"tui\"  (standalone)\n+\n+The resolver helper is import-safe (no heavy module side effects) so it\n+can be unit-tested without spinning up the full gateway.\n+\"\"\"\n+\n+import importlib\n+\n+import pytest\n+\n+\n+def _reload_resolver():\n+    import tui_gateway.server as _srv\n+    importlib.reload(_srv)\n+    return _srv\n+\n+\n[redacted:email]\n+def clean_env(monkeypatch):\n+    monkeypatch.delenv(\"HERMES_DESKTOP\", raising=False)\n+    monkeypatch.delenv(\"HERMES_DESKTOP_TERMINAL\", raising=False)\n+    return monkeypatch\n+\n+\n+class TestResolveSessionPlatform:\n+    def test_standalone_tui_neither_env_set(self, clean_env):\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"tui\"\n+\n+    def test_desktop_chat_backend_gets_desktop_tag(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"desktop\"\n+\n+    def test_desktop_embedded_terminal_pane_stays_tui(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        clean_env.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"tui\"\n+\n+    def test_desktop_terminal_alone_means_standalone_tui(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"tui\"\n+\n+    @pytest.mark.parametrize(\"val\", [\"1\", \"true\", \"yes\", \"on\", \"TRUE\", \"Yes\", \"ON\"])\n+    def test_truthy_variants_recognized(self, clean_env, val):\n+        clean_env.setenv(\"HERMES_DESKTOP\", val)\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"desktop\"\n+\n+    @pytest.mark.parametrize(\"val\", [\"0\", \"false\", \"\", \"no\", \"off\", \"False\"])\n+    def test_falsy_variants_fall_back_to_tui(self, clean_env, val):\n+        clean_env.setenv(\"HERMES_DESKTOP\", val)\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"tui\"\n+\n+    def test_embedded_terminal_overrides_desktop_when_both_set(self, clean_env):\n+        \"\"\"The terminal-pane qualifier must short-circuit the desktop-backend\n+        marker. An embedded TUI is a TUI, not a desktop chat surface.\"\"\"\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        clean_env.setenv(\"HERMES_DESKTOP_TERMINAL\", \"true\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_platform() == \"tui\"\n+\n+\n+class TestResolveSessionSource:\n+    def test_explicit_source_param_wins(self, clean_env):\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(\"telegram\") == \"telegram\"\n+\n+    def test_explicit_empty_source_falls_back_to_env(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(\"\") == \"desktop\"\n+\n+    def test_explicit_none_source_falls_back_to_env(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(None) == \"desktop\"\n+\n+    def test_no_env_no_param_defaults_to_tui(self, clean_env):\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(None) == \"tui\"\n+\n+    def test_embedded_terminal_default_is_tui(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        clean_env.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(None) == \"tui\"\n+\n+    def test_explicit_source_param_resists_env_drift(self, clean_env):\n+        \"\"\"A caller that explicitly passes source=\"cli\" must not be silently\n+        rewritten to \"desktop\" by env vars — the resolver only fills in the\n+        default when one is missing.\"\"\"\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_session_source(\"cli\") == \"cli\"\n+\n+\n+class TestResolveAgentPlatform:\n+    def test_explicit_desktop_source_drives_agent_platform_without_env(self, clean_env):\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_agent_platform(\"desktop\") == \"desktop\"\n+\n+    def test_missing_source_falls_back_to_env_resolved_platform(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_agent_platform(None) == \"desktop\"\n+\n+    def test_explicit_tui_source_keeps_embedded_terminal_as_tui(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._resolve_agent_platform(\"tui\") == \"tui\"\n+\n+\n+class TestSessionSourceFallback:\n+    def test_session_source_uses_existing_session_value(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._session_source({\"source\": \"telegram\"}) == \"telegram\"\n+\n+    def test_session_source_defaults_to_desktop_under_desktop_backend(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._session_source({}) == \"desktop\"\n+        assert _srv._session_source(None) == \"desktop\"\n+\n+    def test_session_source_defaults_to_tui_for_embedded_terminal(self, clean_env):\n+        clean_env.setenv(\"HERMES_DESKTOP\", \"1\")\n+        clean_env.setenv(\"HERMES_DESKTOP_TERMINAL\", \"1\")\n+        _srv = _reload_resolver()\n+        assert _srv._session_source({}) == \"tui\"\n+        assert _srv._session_source(None) == \"tui\""]},{"file":"tui_gateway/server.py","hunks":["@@ -404,12 +404,18 @@ def _load_busy_input_mode() -> str:\n     return raw if raw in {\"queue\", \"steer\", \"interrupt\"} else \"interrupt\"\n \n \n-def _notify_session_boundary(event_type: str, session_id: str | None) -> None:\n+def _notify_session_boundary(\n+    event_type: str, session_id: str | None, platform: str | None = None\n+) -> None:\n     \"\"\"Fire session lifecycle hooks with CLI parity.\"\"\"\n     try:\n         from hermes_cli.plugins import invoke_hook as _invoke_hook\n \n-        _invoke_hook(event_type, session_id=session_id, platform=\"tui\")\n+        _invoke_hook(\n+            event_type,\n+            session_id=session_id,\n+            platform=_resolve_agent_platform(platform),\n+        )\n     except Exception:\n         pass\n ","@@ -477,6 +483,7 @@ def _transfer_active_session_slot(\n     new_lease, limit_message = _claim_active_session_slot(\n         new_session_id,\n         live_session_id=sid,\n+        surface=_session_source(session),\n     )\n     if new_lease is not None:\n         old_lease = session.pop(\"active_session_lease\", None)","@@ -612,7 +619,7 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n \n     session_key = session.get(\"session_key\")\n     session_id = getattr(agent, \"session_id\", None) or session_key\n-    _notify_session_boundary(\"on_session_finalize\", session_id)\n+    _notify_session_boundary(\"on_session_finalize\", session_id, _session_source(session))\n \n     # Mark session ended in DB so it doesn't linger as a ghost row in /resume.\n     # Use session_id (from agent.session_id) not session_key — after compression,","@@ -1322,6 +1329,7 @@ def _build() -> None:\n                 kw = {\"session_db\": session_db}\n                 if resume_sid := current.get(\"resume_session_id\"):\n                     kw[\"session_id\"] = resume_sid\n+                kw[\"platform_override\"] = _session_source(current)\n                 resume_overrides = current.get(\"resume_runtime_overrides\")\n                 if isinstance(resume_overrides, dict) and resume_overrides:\n                     # Cold deferred resume: restore the full persisted runtime","@@ -1401,7 +1409,7 @@ def _build() -> None:\n             with _sessions_lock:\n                 if sid in _sessions:\n                     _sessions[sid][\"_notif_stop\"] = _start_notification_poller(sid, _sessions[sid])\n-            _notify_session_boundary(\"on_session_reset\", key)\n+            _notify_session_boundary(\"on_session_reset\", key, _session_source(current))\n \n             info = _session_info(agent, current)\n             cfg_warn = _probe_config_health(_load_cfg())","@@ -1603,7 +1611,7 @@ def _session_source(session: dict | None) -> str:\n         source = str(session.get(\"source\") or \"\").strip()\n         if source:\n             return source\n-    return \"tui\"\n+    return _resolve_session_platform()\n \n \n def _register_session_cwd(session: dict | None) -> None:","@@ -1947,7 +1955,7 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list:\n         # know the parent workspace pass it explicitly so spawned agents inherit\n         # it instead of falling back to the gateway launch dir.\n         resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)\n-        source = \"tui\"\n+        source = _resolve_session_platform()\n         with _sessions_lock:\n             for sess in list(_sessions.values()):\n                 if sess.get(\"session_key\") == session_key:","@@ -2050,6 +2058,49 @@ def _resolve_model() -> str:\n     return \"anthropic/claude-sonnet-4\"\n \n \n+def _resolve_session_platform() -> str:\n+    \"\"\"Resolve the platform tag for a tui_gateway-routed session.\n+\n+    The desktop app's chat panel and the standalone TUI both speak to this\n+    gateway; without a branch they all get stamped ``platform=\"tui\"``,\n+    which makes the agent think it's talking to a terminal user. That\n+    mis-tag is the root cause of the desktop chat agent suggesting\n+    TUI-only slash commands (``/reload-mcp``, …) to chat-panel users.\n+\n+    Resolution:\n+      * ``HERMES_DESKTOP=1`` and ``HERMES_DESKTOP_TERMINAL`` unset → \"desktop\"\n+        (the chat-panel backend — a graphical React surface, not a terminal).\n+      * ``HERMES_DESKTOP_TERMINAL=1`` → \"tui\"\n+        (``hermes --tui`` running in the desktop's embedded terminal pane;\n+        it IS a TUI, just embedded. The clarifier attached to the tui hint\n+        in system_prompt.py tells the agent about the embedding.)\n+      * neither set → \"tui\"\n+        (standalone ``hermes --tui``.)\n+    \"\"\"\n+    if is_truthy_value(os.environ.get(\"HERMES_DESKTOP\")) and not is_truthy_value(\n+        os.environ.get(\"HERMES_DESKTOP_TERMINAL\")\n+    ):\n+        return \"desktop\"\n+    return \"tui\"\n+\n+\n+def _resolve_session_source(explicit: str | None) -> str:\n+    \"\"\"Default the session DB ``source`` field from the resolved platform.\n+\n+    A caller that explicitly passes ``source`` (e.g. a plugin session tagged\n+    ``\"telegram\"``) keeps its value. Only an empty/None ``source`` falls back\n+    to the env-resolved platform — so env-driven resolution never silently\n+    rewrites a caller's intent.\n+    \"\"\"\n+    if explicit:\n+        return explicit\n+    return _resolve_session_platform()\n+\n+\n+def _resolve_agent_platform(source: str | None) -> str:\n+    return _resolve_session_source(source)\n+\n+\n def _config_model_target() -> tuple[str, str]:\n     \"\"\"(model, provider) currently selected by config.yaml — and ONLY config.\n ","@@ -2528,7 +2579,7 @@ def _load_enabled_toolsets() -> list[str] | None:\n         try:\n             from agent.coding_context import coding_selection\n \n-            selection = coding_selection(platform=\"tui\")\n+            selection = coding_selection(platform=_resolve_session_platform())\n             if selection is not None:\n                 # Fold in `project` here too: this is a GUI-only resolver, and\n                 # the focus-mode coding posture returns before the fallback path","@@ -4222,6 +4273,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict:\n             sid,\n             session[\"session_key\"],\n             session_id=session[\"session_key\"],\n+            platform_override=_session_source(session),\n             **reset_kw,\n         )\n     finally:","@@ -4371,6 +4423,7 @@ def _make_agent(\n     provider_override: str | None = None,\n     reasoning_config_override: dict | None = None,\n     service_tier_override: str | None = None,\n+    platform_override: str | None = None,\n ):\n     from run_agent import AIAgent\n ","@@ -4513,7 +4566,7 @@ def _make_agent(\n         provider_sort=_pr.get(\"sort\"),\n         provider_require_parameters=_pr.get(\"require_parameters\", False),\n         provider_data_collection=_pr.get(\"data_collection\"),\n-        platform=\"tui\",\n+        platform=_resolve_agent_platform(platform_override),\n         session_id=session_id or key,\n         session_db=session_db if session_db is not None else _get_db(),\n         ephemeral_system_prompt=system_prompt or None,","@@ -4534,6 +4587,7 @@ def _init_session(\n     cols: int = 80,\n     cwd: str | None = None,\n     session_db=None,\n+    source: str | None = None,\n ):\n     now = time.time()\n     with _sessions_lock:","@@ -4553,6 +4607,7 @@ def _init_session(\n             \"cols\": cols,\n             \"slash_worker\": None,\n             \"show_reasoning\": _load_show_reasoning(),\n+            \"source\": _resolve_session_source(source),\n             \"tool_progress_mode\": _load_tool_progress_mode(),\n             \"edit_snapshots\": {},\n             \"tool_started_at\": {},","@@ -4621,7 +4676,7 @@ def _init_session(\n     with _sessions_lock:\n         if sid in _sessions:\n             _sessions[sid][\"_notif_stop\"] = _start_notification_poller(sid, _sessions[sid])\n-    _notify_session_boundary(\"on_session_reset\", key)\n+    _notify_session_boundary(\"on_session_reset\", key, _session_source(_sessions.get(sid, {})))\n     _emit(\"session.info\", sid, _session_info(agent, _sessions.get(sid, {})))\n     _schedule_mcp_late_refresh(sid, agent)\n ","@@ -5067,7 +5122,7 @@ def _(rid, params: dict) -> dict:\n     except Exception:\n         explicit_cwd = False\n     resolved_cwd = _completion_cwd(params)\n-    source = str(params.get(\"source\") or \"tui\").strip() or \"tui\"\n+    source = _resolve_session_source(str(params.get(\"source\") or \"\").strip() or None)\n     _enable_gateway_prompts()\n \n     # ``profile`` (app-global remote mode): a new chat started under a non-launch","@@ -5102,7 +5157,9 @@ def _(rid, params: dict) -> dict:\n \n     ready = threading.Event()\n     now = time.time()\n-    lease, limit_message = _claim_active_session_slot(key, live_session_id=sid)\n+    lease, limit_message = _claim_active_session_slot(\n+        key, live_session_id=sid, surface=source\n+    )\n     if limit_message is not None:\n         return _err(rid, 4090, limit_message)\n ","@@ -5528,7 +5585,10 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n     # (resume_session_id keeps the upgrade on the stored conversation).\n     if is_truthy_value(params.get(\"lazy\", False)):\n         sid = uuid.uuid4().hex[:8]\n-        lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)\n+        source = _resolve_session_source(str(params.get(\"source\") or \"\").strip() or None)\n+        lease, limit_message = _claim_active_session_slot(\n+            target, live_session_id=sid, surface=source\n+        )\n         if limit_message is not None:\n             return _err(rid, 4090, limit_message)\n         try:","@@ -5547,7 +5607,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n             cwd=cwd,\n             history=history,\n             lease=lease,\n-            source=str(params.get(\"source\") or \"tui\").strip() or \"tui\",\n+            source=source,\n             close_on_disconnect=is_truthy_value(params.get(\"close_on_disconnect\", False)),\n             profile_home=profile_home,\n             lazy=True,","@@ -5589,7 +5649,10 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n     # session's persisted runtime identity, and is a real (upgradable) session.\n     if not is_truthy_value(params.get(\"eager_build\", False)):\n         sid = uuid.uuid4().hex[:8]\n-        lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)\n+        source = _resolve_session_source(str(params.get(\"source\") or \"\").strip() or None)\n+        lease, limit_message = _claim_active_session_slot(\n+            target, live_session_id=sid, surface=source\n+        )\n         if limit_message is not None:\n             return _err(rid, 4090, limit_message)\n         # Interactive resume routes approvals/clarify through gateway prompts;","@@ -5620,7 +5683,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n             cwd=cwd,\n             history=history,\n             lease=lease,\n-            source=str(params.get(\"source\") or \"tui\").strip() or \"tui\",\n+            source=source,\n             close_on_disconnect=is_truthy_value(params.get(\"close_on_disconnect\", False)),\n             display_history_prefix=prefix,\n             profile_home=profile_home,","@@ -5659,7 +5722,10 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n     # _session_resume_lock across it would stall session.close on the main\n     # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs.\n     sid = uuid.uuid4().hex[:8]\n-    lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)\n+    source = _resolve_session_source(str(params.get(\"source\") or \"\").strip() or None)\n+    lease, limit_message = _claim_active_session_slot(\n+        target, live_session_id=sid, surface=source\n+    )\n     if limit_message is not None:\n         return _err(rid, 4090, limit_message)\n     _enable_gateway_prompts()","@@ -5697,6 +5763,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n                 target,\n                 session_id=target,\n                 session_db=db,\n+                platform_override=source,\n                 **stored_runtime_overrides,\n             )\n         finally:","@@ -5747,6 +5814,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:\n                     cols=cols,\n                     cwd=profile_resume_cwd,\n                     session_db=db,\n+                    source=source,\n                 )\n             finally:\n                 if init_home_token is not None:","@@ -7917,7 +7985,10 @@ def _(rid, params: dict) -> dict:\n         return _err(rid, 4008, \"nothing to branch — send a message first\")\n     new_key = _new_session_key()\n     new_sid = uuid.uuid4().hex[:8]\n-    lease, limit_message = _claim_active_session_slot(new_key, live_session_id=new_sid)\n+    source = _session_source(session)\n+    lease, limit_message = _claim_active_session_slot(\n+        new_key, live_session_id=new_sid, surface=source\n+    )\n     if limit_message is not None:\n         return _err(rid, 4090, limit_message)\n     branch_name = params.get(\"name\", \"\")","@@ -7933,7 +8004,7 @@ def _(rid, params: dict) -> dict:\n             )\n         db.create_session(\n             new_key,\n-            source=_session_source(session),\n+            source=source,\n             model=_resolve_model(),\n             # Stable _branched_from marker so list_sessions_rich() keeps the\n             # branch visible in /resume and /sessions. The TUI branch leaves","@@ -7958,11 +8029,21 @@ def _(rid, params: dict) -> dict:\n     try:\n         tokens = [redacted:secret-assignment]\n         try:\n-            agent = _make_agent(new_sid, new_key, session_id=new_key)\n+            agent = _make_agent(\n+                new_sid,\n+                new_key,\n+                session_id=new_key,\n+                platform_override=source,\n+            )\n         finally:\n             _clear_session_context(tokens)\n         _init_session(\n-            new_sid, new_key, agent, list(history), cols=session.get(\"cols\", 80)\n+            new_sid,\n+            new_key,\n+            agent,\n+            list(history),\n+            cols=session.get(\"cols\", 80),\n+            source=source,\n         )\n         if new_sid in _sessions:\n             _sessions[new_sid][\"active_session_lease\"] = lease\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}