NousResearch/hermes-agent

Memory exhaustion and OOM crashes in TUI and Desktop

Cluster 12682. Data as of

Nothing open on GitHub. Fix merged in #12888 on 20 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #12888. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #12888. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12754 fix(tui): bound retained state against idle OOM

    merged to main

    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

    Show the diff
    • ui-tui/packages/hermes-ink/src/ink/screen.ts
      @@ -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[][] = []
      @@ -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) {
      @@ -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
      @@ -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[]>([])
      @@ -31,6 +53,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
         const clearIn = useCallback(() => {
           setInput('')
           setInputBuf([])
      +    setPasteSnips([])
           setQueueEdit(null)
           setHistoryIdx(null)
           historyDraftRef.current = ''
      @@ -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
      @@ -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
      @@ -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()
       
      @@ -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() {
      @@ -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
      @@ -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

    files: scripts/release.py, ui-tui/src/app/useMainApp.ts

1 PR closed without merging

From the close comments: merged via another change 1

  • #12822 fix(tui): guard empty idle queue drain to stop idle OOM · merged via another change → #12888, #12754
Closed issues · 1
  • Closed#12682TUI mode crashes with "JavaScript heap out of memory" after prolonged use
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#12754, #12888
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
20 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.