# NousResearch/hermes-agent · Memory exhaustion and OOM crashes in TUI and Desktop

## Open now

- Nothing open on GitHub.

Multiple reports and fixes address JavaScript heap out-of-memory conditions and related crashes in both the TUI and Electron desktop environments, including unbounded state retention, idle queue issues, and tool output accumulation.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #12888. Verify it addresses your case.
- **Recommended:** read PR #12888 (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-12888`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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

- #12682: TUI mode crashes with "JavaScript heap out of memory" after prolonged use

## Prior & competing PRs

- **#12754: fix(tui): bound retained state against idle OOM**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `ui-tui/packages/hermes-ink/src/ink/screen.ts`, `ui-tui/src/app/useComposerState.ts`, `ui-tui/src/app/useMainApp.ts`, `ui-tui/src/gatewayClient.ts`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/src/ink/screen.ts`

    ```diff
    @@ -121,6 +121,8 @@ const YELLOW_FG_CODE: AnsiCode = {
       endCode: '\x1b[39m'
     }
     
    +const MAX_TRANSITION_CACHE = 32768
    +
     export class StylePool {
       private ids = new Map<string, number>()
       private styles: AnsiCode[][] = []
    ```

    ```diff
    @@ -160,7 +162,9 @@ export class StylePool {
       /**
        * Returns the pre-serialized ANSI string to transition from one style to
        * another. Cached by (fromId, toId) — zero allocations after first call
    -   * for a given pair.
    +   * for a given pair. Full-clear at MAX_TRANSITION_CACHE guards against
    +   * unbounded growth from ever-expanding id spaces; cache repopulates from
    +   * the next frame's actual transitions.
        */
       transition(fromId: number, toId: number): string {
         if (fromId === toId) {
    ```

    ```diff
    @@ -171,6 +175,10 @@ export class StylePool {
         let str = this.transitionCache.get(key)
     
         if (str === undefined) {
    +      if (this.transitionCache.size >= MAX_TRANSITION_CACHE) {
    +        this.transitionCache.clear()
    +      }
    +
           str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))
           this.transitionCache.set(key, str)
         }
    ```
  - `ui-tui/src/app/useComposerState.ts`

    ```diff
    @@ -16,6 +16,28 @@ import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
     import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
     import { $isBlocked } from './overlayStore.js'
     
    +const PASTE_SNIP_MAX_COUNT = 32
    +const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024
    +
    +const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {
    +  let total = 0
    +  const out: PasteSnippet[] = []
    +
    +  for (let i = snips.length - 1; i >= 0; i--) {
    +    const snip = snips[i]!
    +    const size = snip.text.length
    +
    +    if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) {
    +      break
    +    }
    +
    +    total += size
    +    out.unshift(snip)
    +  }
    +
    +  return out.length === snips.length ? snips : out
    +}
    +
     export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
       const [input, setInput] = useState('')
       const [inputBuf, setInputBuf] = useState<string[]>([])
    ```

    ```diff
    @@ -31,6 +53,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
       const clearIn = useCallback(() => {
         setInput('')
         setInputBuf([])
    +    setPasteSnips([])
         setQueueEdit(null)
         setHistoryIdx(null)
         historyDraftRef.current = ''
    ```

    ```diff
    @@ -68,7 +91,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
           const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
           const insert = `${lead}${label}${tail}`
     
    -      setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
    +      setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }]))
     
           void gw
             .request<{ path?: string }>('paste.collapse', { text: cleanedText })
    ```
  - `ui-tui/src/app/useMainApp.ts`

    ```diff
    @@ -448,6 +448,7 @@ export function useMainApp(gw: GatewayClient) {
         const handler = (ev: GatewayEvent) => onEventRef.current(ev)
     
         const exitHandler = () => {
    +      turnController.reset()
           patchUiState({ busy: false, sid: null, status: 'gateway exited' })
           turnController.pushActivity('gateway exited · /logs to inspect', 'error')
           sys('error: gateway exited')
    ```
  - `ui-tui/src/gatewayClient.ts`

    ```diff
    @@ -7,10 +7,15 @@ import { createInterface } from 'node:readline'
     import type { GatewayEvent } from './gatewayTypes.js'
     
     const MAX_GATEWAY_LOG_LINES = 200
    +const MAX_LOG_LINE_BYTES = 4096
    +const MAX_BUFFERED_EVENTS = 2000
     const MAX_LOG_PREVIEW = 240
     const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)
     const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
     
    +const truncateLine = (line: string) =>
    +  line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line
    +
     const resolvePython = (root: string) => {
       const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
     
    ```

    ```diff
    @@ -69,7 +74,9 @@ export class GatewayClient extends EventEmitter {
           return void this.emit('event', ev)
         }
     
    -    this.bufferedEvents.push(ev)
    +    if (this.bufferedEvents.push(ev) > MAX_BUFFERED_EVENTS) {
    +      this.bufferedEvents.splice(0, this.bufferedEvents.length - MAX_BUFFERED_EVENTS)
    +    }
       }
     
       start() {
    ```

    ```diff
    @@ -121,7 +128,7 @@ export class GatewayClient extends EventEmitter {
     
         this.stderrRl = createInterface({ input: this.proc.stderr! })
         this.stderrRl.on('line', raw => {
    -      const line = raw.trim()
    +      const line = truncateLine(raw.trim())
     
           if (!line) {
             return
    ```

    ```diff
    @@ -181,7 +188,7 @@ export class GatewayClient extends EventEmitter {
       }
     
       private pushLog(line: string) {
    -    if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) {
    +    if (this.logs.push(truncateLine(line)) > MAX_GATEWAY_LOG_LINES) {
           this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
         }
       }
    ```
- **#12888: fix(tui): guard empty idle queue drain to stop idle OOM**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `ui-tui/src/app/useMainApp.ts`
- **#12822: fix(tui): guard empty idle queue drain to stop idle OOM**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#12888`, `#12754`

## Fix status

- Merged to main in PR #12888 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-20`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
