{"repo":"NousResearch/hermes-agent","public_id":"7316","display_label":"Bracketed-paste marker leaks in CLI and desktop","summary":"These issues and pull requests address bracketed-paste escape sequences leaking into user input in both the CLI and Electron desktop app. The work involves stripping these markers before they corrupt persisted content or cause unresponsiveness.","subsystem":null,"counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16367},"resolution":{"state":"resolved","by":"pr-16367,pr-64616"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T14:39:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3028571428571429},"fix_edges":[{"issue_number":7316,"pr_number":16367,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62557,"pr_number":64616,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":7316,"title":"[Bug]: Hermes CLI can leak bracketed-paste markers (^[[200~) into input and become unresponsive in Ghostty","url":null,"state":"closed"},{"kind":"issue","number":62557,"title":"[Bug]: Hermes Desktop (Electron) leaks bracketed-paste markers into user messages, corrupting persisted content (~[[e ×200+ repeats in messages.content)","url":null,"state":"closed"},{"kind":"pr","number":7324,"title":"fix(cli): strip leaked bracketed-paste wrappers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a0fe73bad","number":0},{"kind":"pr","value":"#16367","number":16367}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11504,"title":"fix(cli): strip leaked bracketed-paste markers from user input","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16367,"title":"fix(cli): strip leaked bracketed-paste wrappers (salvage #7324)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py","tests/cli/test_cli_bracketed_paste_sanitizer.py"]},{"kind":"pr","number":62599,"title":"fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":64616,"title":"fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557)","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/chat/composer/index.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/components/assistant-ui/thread/user-edit-composer.tsx","apps/desktop/src/lib/composer-input-sanitize.test.ts","apps/desktop/src/lib/composer-input-sanitize.ts","cli.py","hermes_cli/input_sanitize.py","tests/hermes_cli/test_input_sanitize.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/index.tsx","hunks":["@@ -6,6 +6,7 @@ import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-d\n import { Button } from '@/components/ui/button'\n import { useI18n } from '@/i18n'\n import { chatMessageText } from '@/lib/chat-messages'\n+import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'\n import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'\n import { triggerHaptic } from '@/lib/haptics'\n import { cn } from '@/lib/utils'","@@ -267,7 +268,7 @@ export function ChatBar({\n \n     normalizeComposerEditorDom(editor)\n \n-    const nextDraft = composerPlainText(editor)\n+    const nextDraft = sanitizeComposerInput(composerPlainText(editor))\n \n     if (nextDraft !== draftRef.current) {\n       draftRef.current = nextDraft","@@ -332,7 +333,7 @@ export function ChatBar({\n     // blank lines (common when selecting from terminals, code blocks, web pages)\n     // doesn't dump multiline padding into the composer. Internal newlines are\n     // preserved — only the edges are cleaned up.\n-    const pastedText = event.clipboardData.getData('text').trim()\n+    const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())\n \n     if (!pastedText) {\n       event.preventDefault()"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","hunks":["@@ -6,6 +6,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'\n import { useI18n } from '@/i18n'\n import { stripAnsi } from '@/lib/ansi'\n import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'\n+import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'\n import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'\n import { triggerHaptic } from '@/lib/haptics'\n import { setMutableRef } from '@/lib/mutable-ref'","@@ -471,7 +472,7 @@ export function usePromptActions({\n \n   const submitText = useCallback(\n     async (rawText: string, options?: SubmitTextOptions) => {\n-      const visibleText = rawText.trim()\n+      const visibleText = sanitizeComposerInput(rawText).trim()\n       const attachments = options?.attachments ?? $composerAttachments.get()\n \n       if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {","@@ -596,7 +597,7 @@ export function usePromptActions({\n   // window) so the caller can fall back to queueing the words for the next turn.\n   const steerPrompt = useCallback(\n     async (rawText: string): Promise<boolean> => {\n-      const text = rawText.trim()\n+      const text = sanitizeComposerInput(rawText).trim()\n       const sessionId = activeSessionId || activeSessionIdRef.current\n \n       if (!text || !sessionId) {"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","hunks":["@@ -3,6 +3,7 @@ import { type MutableRefObject, useCallback } from 'react'\n import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'\n import type { Translations } from '@/i18n'\n import { type ChatMessage, textPart } from '@/lib/chat-messages'\n+import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'\n import { optimisticAttachmentRef } from '@/lib/chat-runtime'\n import { setMutableRef } from '@/lib/mutable-ref'\n import {","@@ -67,7 +68,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n \n   return useCallback(\n     async (rawText: string, options?: SubmitTextOptions) => {\n-      const visibleText = rawText.trim()\n+      const visibleText = sanitizeComposerInput(rawText).trim()\n       const usingComposerAttachments = !options?.attachments\n \n       // Drop undefined/null holes a session switch or draft restore can leave in"]},{"file":"apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx","hunks":["@@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon'\n import type { HermesGateway } from '@/hermes'\n import { useI18n } from '@/i18n'\n import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'\n+import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'\n import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'\n import { triggerHaptic } from '@/lib/haptics'\n import { Loader2Icon } from '@/lib/icons'","@@ -188,7 +189,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess\n \n   const syncDraftFromEditor = useCallback(\n     (editor: HTMLDivElement) => {\n-      const nextDraft = composerPlainText(editor)\n+      const nextDraft = sanitizeComposerInput(composerPlainText(editor))\n \n       if (nextDraft !== draftRef.current) {\n         draftRef.current = nextDraft","@@ -477,7 +478,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess\n   }\n \n   const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {\n-    const pastedText = event.clipboardData.getData('text')\n+    const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))\n \n     if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {\n       event.preventDefault()"]},{"file":"apps/desktop/src/lib/composer-input-sanitize.test.ts","hunks":["@@ -0,0 +1,51 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import {\n+  collapseRepeatedInputArtifacts,\n+  sanitizeComposerInput,\n+  stripLeakedBracketedPasteWrappers\n+} from './composer-input-sanitize'\n+\n+describe('stripLeakedBracketedPasteWrappers', () => {\n+  it('leaves plain text unchanged', () => {\n+    expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world')\n+  })\n+\n+  it('strips canonical escape wrappers', () => {\n+    expect(stripLeakedBracketedPasteWrappers('\\x1b[200~hello\\x1b[201~')).toBe('hello')\n+  })\n+\n+  it('keeps embedded literal bracket forms', () => {\n+    const text = 'literal[200~tag and literal[201~tag should stay'\n+    expect(stripLeakedBracketedPasteWrappers(text)).toBe(text)\n+  })\n+})\n+\n+describe('collapseRepeatedInputArtifacts', () => {\n+  it('removes the desktop corruption tail from #62557', () => {\n+    const prefix = '需要时随时叫我。'\n+    const tail = '[e~[[e' + '~[[e'.repeat(20)\n+    expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix)\n+  })\n+\n+  it('preserves a mid-string marker followed by valid suffix', () => {\n+    const text = 'notes ~[[e more text here'\n+    expect(collapseRepeatedInputArtifacts(text)).toBe(text)\n+  })\n+\n+  it('preserves trailing punctuation that is not the corruption signature', () => {\n+    expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....')\n+  })\n+\n+  it('does not strip when fewer than minRepeats markers appear at the tail', () => {\n+    const text = 'hello~[[e~[[e'\n+    expect(collapseRepeatedInputArtifacts(text)).toBe(text)\n+  })\n+})\n+\n+describe('sanitizeComposerInput', () => {\n+  it('normalizes wrappers and repeated artifact tails together', () => {\n+    const corrupted = 'hello[' + '~[[e'.repeat(8)\n+    expect(sanitizeComposerInput(corrupted)).toBe('hello')\n+  })\n+})"]},{"file":"apps/desktop/src/lib/composer-input-sanitize.ts","hunks":["@@ -0,0 +1,71 @@\n+/**\n+ * Strip terminal bracketed-paste leaks and repeated artifact tails from composer\n+ * text before it is shown in the UI or sent to the gateway.\n+ *\n+ * Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path).\n+ */\n+\n+const BRACKETED_PASTE_BOUNDARY_START = /(^|[\\s\\n>:\\]\\)])\\[200~/g\n+const BRACKETED_PASTE_BOUNDARY_END = /\\[201~(?=$|[\\s\\n<[():;.,!?])/g\n+const BRACKETED_PASTE_DEGRADED_START = /(^|[\\s\\n>:\\]\\)])00~/g\n+const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\\s\\n<[():;.,!?])/g\n+\n+const DESKTOP_PASTE_ARTIFACT = '~[[e'\n+\n+/** Strip leaked bracketed-paste wrapper markers from user-visible text. */\n+export function stripLeakedBracketedPasteWrappers(text: string): string {\n+  if (!text) {\n+    return text\n+  }\n+\n+  let cleaned = text\n+    .replace(/\\x1b\\[200~/g, '')\n+    .replace(/\\x1b\\[201~/g, '')\n+    .replace(/\\^\\[\\[200~/g, '')\n+    .replace(/\\^\\[\\[201~/g, '')\n+\n+  cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1')\n+  cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '')\n+  cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1')\n+  cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '')\n+\n+  return cleaned\n+}\n+\n+/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */\n+export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string {\n+  if (!text) {\n+    return text\n+  }\n+\n+  const marker = DESKTOP_PASTE_ARTIFACT\n+  let index = text.length\n+  let repeatCount = 0\n+\n+  while (index >= marker.length && text.slice(index - marker.length, index) === marker) {\n+    repeatCount += 1\n+    index -= marker.length\n+  }\n+\n+  if (repeatCount < minRepeats) {\n+    return text\n+  }\n+\n+  let start = index\n+  if (start >= 2 && text.slice(start - 2, start) === '[e') {\n+    start -= 2\n+  } else if (start >= 1 && text[start - 1] === '[') {\n+    start -= 1\n+  }\n+\n+  return text.slice(0, start)\n+}\n+\n+/** Normalize composer text before submit or draft persistence. */\n+export function sanitizeComposerInput(text: string): string {\n+  if (!text) {\n+    return text\n+  }\n+\n+  return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text))\n+}"]},{"file":"cli.py","hunks":["@@ -2995,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:\n \n \n def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:\n-    \"\"\"Strip leaked bracketed-paste wrapper markers from user-visible text.\n+    from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers\n \n-    Defensive normalization for cases where terminal/prompt_toolkit parsing\n-    fails and bracketed-paste markers end up in the buffer as literal text.\n-\n-    We strip canonical wrappers unconditionally and also handle degraded\n-    visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they\n-    look like wrapper boundaries, not arbitrary user content.\n-    \"\"\"\n-    if not text:\n-        return text\n-\n-    text = (\n-        text.replace(\"\\x1b[200~\", \"\")\n-        .replace(\"\\x1b[201~\", \"\")\n-        .replace(\"^[[200~\", \"\")\n-        .replace(\"^[[201~\", \"\")\n-    )\n-    text = re.sub(r\"(^|[\\s\\n>:\\]\\)])\\[200~\", r\"\\1\", text)\n-    text = re.sub(r\"\\[201~(?=$|[\\s\\n<\\[\\(\\):;.,!?])\", \"\", text)\n-    text = re.sub(r\"(^|[\\s\\n>:\\]\\)])00~\", r\"\\1\", text)\n-    text = re.sub(r\"01~(?=$|[\\s\\n<\\[\\(\\):;.,!?])\", \"\", text)\n-    return text\n+    return strip_leaked_bracketed_paste_wrappers(text)\n \n \n def _apply_bracketed_paste_timeout_patch() -> None:"]},{"file":"hermes_cli/input_sanitize.py","hunks":["@@ -0,0 +1,70 @@\n+\"\"\"Sanitize user prompt text leaked from terminal / paste control sequences.\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+\n+_BRACKETED_PASTE_BOUNDARY_START = re.compile(r\"(^|[\\s\\n>:\\]\\)])\\[200~\")\n+_BRACKETED_PASTE_BOUNDARY_END = re.compile(r\"\\[201~(?=$|[\\s\\n<\\[\\(\\):;.,!?])\")\n+_BRACKETED_PASTE_DEGRADED_START = re.compile(r\"(^|[\\s\\n>:\\]\\)])00~\")\n+_BRACKETED_PASTE_DEGRADED_END = re.compile(r\"01~(?=$|[\\s\\n<\\[\\(\\):;.,!?])\")\n+\n+# Corruption signature from desktop bracketed-paste leaks (#62557).\n+_DESKTOP_PASTE_ARTIFACT = \"~[[e\"\n+\n+\n+def strip_leaked_bracketed_paste_wrappers(text: str) -> str:\n+    \"\"\"Strip leaked bracketed-paste wrapper markers from user-visible text.\n+\n+    Defensive normalization for cases where terminal/prompt_toolkit parsing\n+    fails and bracketed-paste markers end up in the buffer as literal text.\n+\n+    Canonical wrappers are stripped unconditionally. Degraded visible forms like\n+    ``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries\n+    so embedded literals such as ``literal[200~tag`` stay intact.\n+    \"\"\"\n+    if not text:\n+        return text\n+\n+    text = (\n+        text.replace(\"\\x1b[200~\", \"\")\n+        .replace(\"\\x1b[201~\", \"\")\n+        .replace(\"^[[200~\", \"\")\n+        .replace(\"^[[201~\", \"\")\n+    )\n+    text = _BRACKETED_PASTE_BOUNDARY_START.sub(r\"\\1\", text)\n+    text = _BRACKETED_PASTE_BOUNDARY_END.sub(\"\", text)\n+    text = _BRACKETED_PASTE_DEGRADED_START.sub(r\"\\1\", text)\n+    text = _BRACKETED_PASTE_DEGRADED_END.sub(\"\", text)\n+    return text\n+\n+\n+def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str:\n+    \"\"\"Drop a trailing run of the desktop ~[[e corruption signature (#62557).\"\"\"\n+    if not text:\n+        return text\n+\n+    marker = _DESKTOP_PASTE_ARTIFACT\n+    index = len(text)\n+    repeat_count = 0\n+    while index >= len(marker) and text[index - len(marker) : index] == marker:\n+        repeat_count += 1\n+        index -= len(marker)\n+\n+    if repeat_count < min_repeats:\n+        return text\n+\n+    start = index\n+    if start >= 2 and text[start - 2 : start] == \"[e\":\n+        start -= 2\n+    elif start >= 1 and text[start - 1] == \"[\":\n+        start -= 1\n+    return text[:start]\n+\n+\n+def sanitize_user_prompt_text(text: str) -> str:\n+    \"\"\"Normalize user-authored prompt text before persistence or model input.\"\"\"\n+    if not isinstance(text, str) or not text:\n+        return text\n+    cleaned = strip_leaked_bracketed_paste_wrappers(text)\n+    return collapse_repeated_input_artifacts(cleaned)"]},{"file":"tests/hermes_cli/test_input_sanitize.py","hunks":["@@ -0,0 +1,51 @@\n+\"\"\"Tests for shared user prompt input sanitization.\"\"\"\n+\n+from hermes_cli.input_sanitize import (\n+    collapse_repeated_input_artifacts,\n+    sanitize_user_prompt_text,\n+    strip_leaked_bracketed_paste_wrappers,\n+)\n+\n+\n+class TestStripLeakedBracketedPasteWrappers:\n+    def test_plain_text_unchanged(self):\n+        assert strip_leaked_bracketed_paste_wrappers(\"hello world\") == \"hello world\"\n+\n+    def test_strips_canonical_escape_wrappers(self):\n+        assert strip_leaked_bracketed_paste_wrappers(\"\\x1b[200~hello\\x1b[201~\") == \"hello\"\n+\n+    def test_strips_visible_caret_escape_wrappers(self):\n+        assert strip_leaked_bracketed_paste_wrappers(\"^[[200~hello^[[201~\") == \"hello\"\n+\n+    def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):\n+        text = \"literal[200~tag and literal[201~tag should stay\"\n+        assert strip_leaked_bracketed_paste_wrappers(text) == text\n+\n+\n+class TestCollapseRepeatedInputArtifacts:\n+    def test_issue_62557_corruption_tail(self):\n+        prefix = \"需要时随时叫我。\"\n+        tail = \"[e~[[e\" + \"~[[e\" * 20\n+        assert collapse_repeated_input_artifacts(prefix + tail) == prefix\n+\n+    def test_plain_text_unchanged(self):\n+        text = \"build00~tag should stay\"\n+        assert collapse_repeated_input_artifacts(text) == text\n+\n+    def test_mid_string_marker_followed_by_suffix_preserved(self):\n+        text = \"notes ~[[e more text here\"\n+        assert collapse_repeated_input_artifacts(text) == text\n+\n+    def test_trailing_punctuation_preserved(self):\n+        assert collapse_repeated_input_artifacts(\"wait....\") == \"wait....\"\n+\n+    def test_insufficient_tail_repeats_preserved(self):\n+        text = \"hello~[[e~[[e\"\n+        assert collapse_repeated_input_artifacts(text) == text\n+\n+\n+class TestSanitizeUserPromptText:\n+    def test_combines_wrapper_strip_and_tail_collapse(self):\n+        prefix = \"hello[\"\n+        corrupted = prefix + \"~[[e\" * 8\n+        assert sanitize_user_prompt_text(corrupted) == \"hello\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -5093,6 +5093,51 @@ def start(self):\n         server._sessions.pop(\"sid\", None)\n \n \n+def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):\n+    \"\"\"prompt.submit must sanitize corrupted user text before run_conversation.\"\"\"\n+    captured: dict[str, str] = {}\n+\n+    class _Agent:\n+        def run_conversation(\n+            self, prompt, conversation_history=None, stream_callback=None\n+        ):\n+            captured[\"prompt\"] = prompt\n+            return {\n+                \"final_response\": \"ok\",\n+                \"messages\": [{\"role\": \"assistant\", \"content\": \"ok\"}],\n+            }\n+\n+    class _ImmediateThread:\n+        def __init__(self, target=None, daemon=None, **kw):\n+            self._target = target\n+\n+        def start(self):\n+            self._target()\n+\n+    corrupted = \"hello[\" + \"~[[e\" * 8\n+    server._sessions[\"sid\"] = _session(agent=_Agent())\n+    try:\n+        monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+        monkeypatch.setattr(server, \"_get_usage\", lambda _a: {})\n+        monkeypatch.setattr(server, \"render_message\", lambda _t, _c: \"\")\n+        monkeypatch.setattr(server, \"_emit\", lambda *a: None)\n+        monkeypatch.setattr(server, \"_start_agent_build\", lambda *a, **k: None)\n+        monkeypatch.setattr(server, \"_ensure_session_db_row\", lambda *a, **k: None)\n+        monkeypatch.setattr(server, \"_persist_branch_seed\", lambda *a, **k: None)\n+\n+        resp = server.handle_request(\n+            {\n+                \"id\": \"1\",\n+                \"method\": \"prompt.submit\",\n+                \"params\": {\"session_id\": \"sid\", \"text\": corrupted},\n+            }\n+        )\n+        assert resp.get(\"result\"), f\"got error: {resp.get('error')}\"\n+        assert captured[\"prompt\"] == \"hello\"\n+    finally:\n+        server._sessions.pop(\"sid\", None)\n+\n+\n def test_prompt_submit_history_version_match_persists_normally(monkeypatch):\n     \"\"\"Regression guard: the backstop does not affect the happy path.\"\"\"\n "]},{"file":"tui_gateway/server.py","hunks":["@@ -8455,7 +8455,11 @@ def _(rid, params: dict) -> dict:\n \n @method(\"prompt.submit\")\n def _(rid, params: dict) -> dict:\n-    sid, text = params.get(\"session_id\", \"\"), params.get(\"text\", \"\")\n+    from hermes_cli.input_sanitize import sanitize_user_prompt_text\n+\n+    sid = params.get(\"session_id\", \"\")\n+    raw_text = params.get(\"text\", \"\")\n+    text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text\n     truncate_user_ordinal = params.get(\"truncate_before_user_ordinal\")\n     session, err = _sess_nowait(params, rid)\n     if err:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}