# NousResearch/hermes-agent · Bracketed-paste marker leaks in CLI and desktop

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 7 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #16367. Verify it addresses your case.
- **Recommended:** read PR #16367 (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: `resolved` · deciding PR(s): `pr-16367,pr-64616`. 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.30`: 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

- #7316: \[Bug\]: Hermes CLI can leak bracketed-paste markers (^\[\[200~) into input and become unresponsive in Ghostty
- #62557: \[Bug\]: Hermes Desktop (Electron) leaks bracketed-paste markers into user messages, corrupting persisted content (~\[\[e ×200+ repeats in messages.content)

## Prior & competing PRs

- **#16367: fix(cli): strip leaked bracketed-paste wrappers (salvage #7324)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `scripts/release.py`, `tests/cli/test_cli_bracketed_paste_sanitizer.py`
- **#64616: fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `apps/desktop/src/app/chat/composer/index.tsx`

    ```diff
    @@ -6,6 +6,7 @@ import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-d
     import { Button } from '@/components/ui/button'
     import { useI18n } from '@/i18n'
     import { chatMessageText } from '@/lib/chat-messages'
    +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
     import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
     import { triggerHaptic } from '@/lib/haptics'
     import { cn } from '@/lib/utils'
    ```

    ```diff
    @@ -267,7 +268,7 @@ export function ChatBar({
     
         normalizeComposerEditorDom(editor)
     
    -    const nextDraft = composerPlainText(editor)
    +    const nextDraft = sanitizeComposerInput(composerPlainText(editor))
     
         if (nextDraft !== draftRef.current) {
           draftRef.current = nextDraft
    ```

    ```diff
    @@ -332,7 +333,7 @@ export function ChatBar({
         // blank lines (common when selecting from terminals, code blocks, web pages)
         // doesn't dump multiline padding into the composer. Internal newlines are
         // preserved — only the edges are cleaned up.
    -    const pastedText = event.clipboardData.getData('text').trim()
    +    const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
     
         if (!pastedText) {
           event.preventDefault()
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts`

    ```diff
    @@ -6,6 +6,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
     import { useI18n } from '@/i18n'
     import { stripAnsi } from '@/lib/ansi'
     import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
    +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
     import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
     import { triggerHaptic } from '@/lib/haptics'
     import { setMutableRef } from '@/lib/mutable-ref'
    ```

    ```diff
    @@ -471,7 +472,7 @@ export function usePromptActions({
     
       const submitText = useCallback(
         async (rawText: string, options?: SubmitTextOptions) => {
    -      const visibleText = rawText.trim()
    +      const visibleText = sanitizeComposerInput(rawText).trim()
           const attachments = options?.attachments ?? $composerAttachments.get()
     
           if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
    ```

    ```diff
    @@ -596,7 +597,7 @@ export function usePromptActions({
       // window) so the caller can fall back to queueing the words for the next turn.
       const steerPrompt = useCallback(
         async (rawText: string): Promise<boolean> => {
    -      const text = rawText.trim()
    +      const text = sanitizeComposerInput(rawText).trim()
           const sessionId = activeSessionId || activeSessionIdRef.current
     
           if (!text || !sessionId) {
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts`

    ```diff
    @@ -3,6 +3,7 @@ import { type MutableRefObject, useCallback } from 'react'
     import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
     import type { Translations } from '@/i18n'
     import { type ChatMessage, textPart } from '@/lib/chat-messages'
    +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
     import { optimisticAttachmentRef } from '@/lib/chat-runtime'
     import { setMutableRef } from '@/lib/mutable-ref'
     import {
    ```

    ```diff
    @@ -67,7 +68,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
     
       return useCallback(
         async (rawText: string, options?: SubmitTextOptions) => {
    -      const visibleText = rawText.trim()
    +      const visibleText = sanitizeComposerInput(rawText).trim()
           const usingComposerAttachments = !options?.attachments
     
           // Drop undefined/null holes a session switch or draft restore can leave in
    ```
  - `apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx`

    ```diff
    @@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon'
     import type { HermesGateway } from '@/hermes'
     import { useI18n } from '@/i18n'
     import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
    +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
     import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
     import { triggerHaptic } from '@/lib/haptics'
     import { Loader2Icon } from '@/lib/icons'
    ```

    ```diff
    @@ -188,7 +189,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
     
       const syncDraftFromEditor = useCallback(
         (editor: HTMLDivElement) => {
    -      const nextDraft = composerPlainText(editor)
    +      const nextDraft = sanitizeComposerInput(composerPlainText(editor))
     
           if (nextDraft !== draftRef.current) {
             draftRef.current = nextDraft
    ```

    ```diff
    @@ -477,7 +478,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
       }
     
       const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
    -    const pastedText = event.clipboardData.getData('text')
    +    const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
     
         if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
           event.preventDefault()
    ```
  - `apps/desktop/src/lib/composer-input-sanitize.test.ts`

    ```diff
    @@ -0,0 +1,51 @@
    +import { describe, expect, it } from 'vitest'
    +
    +import {
    +  collapseRepeatedInputArtifacts,
    +  sanitizeComposerInput,
    +  stripLeakedBracketedPasteWrappers
    +} from './composer-input-sanitize'
    +
    +describe('stripLeakedBracketedPasteWrappers', () => {
    +  it('leaves plain text unchanged', () => {
    +    expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world')
    +  })
    +
    +  it('strips canonical escape wrappers', () => {
    +    expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello')
    +  })
    +
    +  it('keeps embedded literal bracket forms', () => {
    +    const text = 'literal[200~tag and literal[201~tag should stay'
    +    expect(stripLeakedBracketedPasteWrappers(text)).toBe(text)
    +  })
    +})
    +
    +describe('collapseRepeatedInputArtifacts', () => {
    +  it('removes the desktop corruption tail from #62557', () => {
    +    const prefix = '需要时随时叫我。'
    +    const tail = '[e~[[e' + '~[[e'.repeat(20)
    +    expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix)
    +  })
    +
    +  it('preserves a mid-string marker followed by valid suffix', () => {
    +    const text = 'notes ~[[e more text here'
    +    expect(collapseRepeatedInputArtifacts(text)).toBe(text)
    +  })
    +
    +  it('preserves trailing punctuation that is not the corruption signature', () => {
    +    expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....')
    +  })
    +
    +  it('does not strip when fewer than minRepeats markers appear at the tail', () => {
    +    const text = 'hello~[[e~[[e'
    +    expect(collapseRepeatedInputArtifacts(text)).toBe(text)
    +  })
    +})
    +
    +describe('sanitizeComposerInput', () => {
    +  it('normalizes wrappers and repeated artifact tails together', () => {
    +    const corrupted = 'hello[' + '~[[e'.repeat(8)
    +    expect(sanitizeComposerInput(corrupted)).toBe('hello')
    +  })
    +})
    ```
  - `apps/desktop/src/lib/composer-input-sanitize.ts`

    ```diff
    @@ -0,0 +1,71 @@
    +/**
    + * Strip terminal bracketed-paste leaks and repeated artifact tails from composer
    + * text before it is shown in the UI or sent to the gateway.
    + *
    + * Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path).
    + */
    +
    +const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\]\)])\[200~/g
    +const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g
    +const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\]\)])00~/g
    +const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g
    +
    +const DESKTOP_PASTE_ARTIFACT = '~[[e'
    +
    +/** Strip leaked bracketed-paste wrapper markers from user-visible text. */
    +export function stripLeakedBracketedPasteWrappers(text: string): string {
    +  if (!text) {
    +    return text
    +  }
    +
    +  let cleaned = text
    +    .replace(/\x1b\[200~/g, '')
    +    .replace(/\x1b\[201~/g, '')
    +    .replace(/\^\[\[200~/g, '')
    +    .replace(/\^\[\[201~/g, '')
    +
    +  cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1')
    +  cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '')
    +  cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1')
    +  cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '')
    +
    +  return cleaned
    +}
    +
    +/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */
    +export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string {
    +  if (!text) {
    +    return text
    +  }
    +
    +  const marker = DESKTOP_PASTE_ARTIFACT
    +  let index = text.length
    +  let repeatCount = 0
    +
    +  while (index >= marker.length && text.slice(index - marker.length, index) === marker) {
    +    repeatCount += 1
    +    index -= marker.length
    +  }
    +
    +  if (repeatCount < minRepeats) {
    +    return text
    +  }
    +
    +  let start = index
    +  if (start >= 2 && text.slice(start - 2, start) === '[e') {
    +    start -= 2
    +  } else if (start >= 1 && text[start - 1] === '[') {
    +    start -= 1
    +  }
    +
    +  return text.slice(0, start)
    +}
    +
    +/** Normalize composer text before submit or draft persistence. */
    +export function sanitizeComposerInput(text: string): string {
    +  if (!text) {
    +    return text
    +  }
    +
    +  return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text))
    +}
    ```
  - `cli.py`

    ```diff
    @@ -2995,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
     
     
     def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
    -    """Strip leaked bracketed-paste wrapper markers from user-visible text.
    +    from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers
     
    -    Defensive normalization for cases where terminal/prompt_toolkit parsing
    -    fails and bracketed-paste markers end up in the buffer as literal text.
    -
    -    We strip canonical wrappers unconditionally and also handle degraded
    -    visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they
    -    look like wrapper boundaries, not arbitrary user content.
    -    """
    -    if not text:
    -        return text
    -
    -    text = (
    -        text.replace("\x1b[200~", "")
    -        .replace("\x1b[201~", "")
    -        .replace("^[[200~", "")
    -        .replace("^[[201~", "")
    -    )
    -    text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text)
    -    text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
    -    text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text)
    -    text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
    -    return text
    +    return strip_leaked_bracketed_paste_wrappers(text)
     
     
     def _apply_bracketed_paste_timeout_patch() -> None:
    ```
  - `hermes_cli/input_sanitize.py`

    ```diff
    @@ -0,0 +1,70 @@
    +"""Sanitize user prompt text leaked from terminal / paste control sequences."""
    +
    +from __future__ import annotations
    +
    +import re
    +
    +_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~")
    +_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])")
    +_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~")
    +_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])")
    +
    +# Corruption signature from desktop bracketed-paste leaks (#62557).
    +_DESKTOP_PASTE_ARTIFACT = "~[[e"
    +
    +
    +def strip_leaked_bracketed_paste_wrappers(text: str) -> str:
    +    """Strip leaked bracketed-paste wrapper markers from user-visible text.
    +
    +    Defensive normalization for cases where terminal/prompt_toolkit parsing
    +    fails and bracketed-paste markers end up in the buffer as literal text.
    +
    +    Canonical wrappers are stripped unconditionally. Degraded visible forms like
    +    ``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries
    +    so embedded literals such as ``literal[200~tag`` stay intact.
    +    """
    +    if not text:
    +        return text
    +
    +    text = (
    +        text.replace("\x1b[200~", "")
    +        .replace("\x1b[201~", "")
    +        .replace("^[[200~", "")
    +        .replace("^[[201~", "")
    +    )
    +    text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text)
    +    text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text)
    +    text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text)
    +    text = _BRACKETED_PASTE_DEGRADED_END.sub("", text)
    +    return text
    +
    +
    +def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str:
    +    """Drop a trailing run of the desktop ~[[e corruption signature (#62557)."""
    +    if not text:
    +        return text
    +
    +    marker = _DESKTOP_PASTE_ARTIFACT
    +    index = len(text)
    +    repeat_count = 0
    +    while index >= len(marker) and text[index - len(marker) : index] == marker:
    +        repeat_count += 1
    +        index -= len(marker)
    +
    +    if repeat_count < min_repeats:
    +        return text
    +
    +    start = index
    +    if start >= 2 and text[start - 2 : start] == "[e":
    +        start -= 2
    +    elif start >= 1 and text[start - 1] == "[":
    +        start -= 1
    +    return text[:start]
    +
    +
    +def sanitize_user_prompt_text(text: str) -> str:
    +    """Normalize user-authored prompt text before persistence or model input."""
    +    if not isinstance(text, str) or not text:
    +        return text
    +    cleaned = strip_leaked_bracketed_paste_wrappers(text)
    +    return collapse_repeated_input_artifacts(cleaned)
    ```
  - `tests/hermes_cli/test_input_sanitize.py`

    ```diff
    @@ -0,0 +1,51 @@
    +"""Tests for shared user prompt input sanitization."""
    +
    +from hermes_cli.input_sanitize import (
    +    collapse_repeated_input_artifacts,
    +    sanitize_user_prompt_text,
    +    strip_leaked_bracketed_paste_wrappers,
    +)
    +
    +
    +class TestStripLeakedBracketedPasteWrappers:
    +    def test_plain_text_unchanged(self):
    +        assert strip_leaked_bracketed_paste_wrappers("hello world") == "hello world"
    +
    +    def test_strips_canonical_escape_wrappers(self):
    +        assert strip_leaked_bracketed_paste_wrappers("\x1b[200~hello\x1b[201~") == "hello"
    +
    +    def test_strips_visible_caret_escape_wrappers(self):
    +        assert strip_leaked_bracketed_paste_wrappers("^[[200~hello^[[201~") == "hello"
    +
    +    def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):
    +        text = "literal[200~tag and literal[201~tag should stay"
    +        assert strip_leaked_bracketed_paste_wrappers(text) == text
    +
    +
    +class TestCollapseRepeatedInputArtifacts:
    +    def test_issue_62557_corruption_tail(self):
    +        prefix = "需要时随时叫我。"
    +        tail = "[e~[[e" + "~[[e" * 20
    +        assert collapse_repeated_input_artifacts(prefix + tail) == prefix
    +
    +    def test_plain_text_unchanged(self):
    +        text = "build00~tag should stay"
    +        assert collapse_repeated_input_artifacts(text) == text
    +
    +    def test_mid_string_marker_followed_by_suffix_preserved(self):
    +        text = "notes ~[[e more text here"
    +        assert collapse_repeated_input_artifacts(text) == text
    +
    +    def test_trailing_punctuation_preserved(self):
    +        assert collapse_repeated_input_artifacts("wait....") == "wait...."
    +
    +    def test_insufficient_tail_repeats_preserved(self):
    +        text = "hello~[[e~[[e"
    +        assert collapse_repeated_input_artifacts(text) == text
    +
    +
    +class TestSanitizeUserPromptText:
    +    def test_combines_wrapper_strip_and_tail_collapse(self):
    +        prefix = "hello["
    +        corrupted = prefix + "~[[e" * 8
    +        assert sanitize_user_prompt_text(corrupted) == "hello"
    ```
  - `tests/test_tui_gateway_server.py`

    ```diff
    @@ -5093,6 +5093,51 @@ def start(self):
             server._sessions.pop("sid", None)
     
     
    +def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):
    +    """prompt.submit must sanitize corrupted user text before run_conversation."""
    +    captured: dict[str, str] = {}
    +
    +    class _Agent:
    +        def run_conversation(
    +            self, prompt, conversation_history=None, stream_callback=None
    +        ):
    +            captured["prompt"] = prompt
    +            return {
    +                "final_response": "ok",
    +                "messages": [{"role": "assistant", "content": "ok"}],
    +            }
    +
    +    class _ImmediateThread:
    +        def __init__(self, target=None, daemon=None, **kw):
    +            self._target = target
    +
    +        def start(self):
    +            self._target()
    +
    +    corrupted = "hello[" + "~[[e" * 8
    +    server._sessions["sid"] = _session(agent=_Agent())
    +    try:
    +        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    +        monkeypatch.setattr(server, "_get_usage", lambda _a: {})
    +        monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
    +        monkeypatch.setattr(server, "_emit", lambda *a: None)
    +        monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
    +        monkeypatch.setattr(server, "_ensure_session_db_row", lambda *a, **k: None)
    +        monkeypatch.setattr(server, "_persist_branch_seed", lambda *a, **k: None)
    +
    +        resp = server.handle_request(
    +            {
    +                "id": "1",
    +                "method": "prompt.submit",
    +                "params": {"session_id": "sid", "text": corrupted},
    +            }
    +        )
    +        assert resp.get("result"), f"got error: {resp.get('error')}"
    +        assert captured["prompt"] == "hello"
    +    finally:
    +        server._sessions.pop("sid", None)
    +
    +
     def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
         """Regression guard: the backstop does not affect the happy path."""
     
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -8455,7 +8455,11 @@ def _(rid, params: dict) -> dict:
     
     @method("prompt.submit")
     def _(rid, params: dict) -> dict:
    -    sid, text = params.get("session_id", ""), params.get("text", "")
    +    from hermes_cli.input_sanitize import sanitize_user_prompt_text
    +
    +    sid = params.get("session_id", "")
    +    raw_text = params.get("text", "")
    +    text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text
         truncate_user_ordinal = params.get("truncate_before_user_ordinal")
         session, err = _sess_nowait(params, rid)
         if err:
    ```
- **#7324: fix(cli): strip leaked bracketed-paste wrappers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `a0fe73bad`, `#16367`
- **#11504: fix(cli): strip leaked bracketed-paste markers from user input**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#62599: fix(desktop): strip bracketed-paste leaks before prompt persistence (#62557)**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #16367 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-15`.
