{"repo":"NousResearch/hermes-agent","public_id":"12682","display_label":"Memory exhaustion and OOM crashes in TUI and Desktop","summary":"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.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12888,"merged_at":"2026-04-20T07:42:11+00:00"},"resolution":{"state":"resolved","by":"pr-12888"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T07:42:11+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12682,"pr_number":12754,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-20T01:43:40+00:00"},{"issue_number":12682,"pr_number":12888,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-20T07:42:11+00:00"}],"members":[{"kind":"issue","number":12682,"title":"TUI mode crashes with \"JavaScript heap out of memory\" after prolonged use","url":null,"state":"closed"},{"kind":"pr","number":12754,"title":"fix(tui): bound retained state against idle OOM","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"ui-tui/packages/hermes-ink/src/ink/screen.ts","hunks":["@@ -121,6 +121,8 @@ const YELLOW_FG_CODE: AnsiCode = {\n   endCode: '\\x1b[39m'\n }\n \n+const MAX_TRANSITION_CACHE = 32768\n+\n export class StylePool {\n   private ids = new Map<string, number>()\n   private styles: AnsiCode[][] = []","@@ -160,7 +162,9 @@ export class StylePool {\n   /**\n    * Returns the pre-serialized ANSI string to transition from one style to\n    * another. Cached by (fromId, toId) — zero allocations after first call\n-   * for a given pair.\n+   * for a given pair. Full-clear at MAX_TRANSITION_CACHE guards against\n+   * unbounded growth from ever-expanding id spaces; cache repopulates from\n+   * the next frame's actual transitions.\n    */\n   transition(fromId: number, toId: number): string {\n     if (fromId === toId) {","@@ -171,6 +175,10 @@ export class StylePool {\n     let str = this.transitionCache.get(key)\n \n     if (str === undefined) {\n+      if (this.transitionCache.size >= MAX_TRANSITION_CACHE) {\n+        this.transitionCache.clear()\n+      }\n+\n       str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))\n       this.transitionCache.set(key, str)\n     }"]},{"file":"ui-tui/src/app/useComposerState.ts","hunks":["@@ -16,6 +16,28 @@ import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'\n import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'\n import { $isBlocked } from './overlayStore.js'\n \n+const PASTE_SNIP_MAX_COUNT = 32\n+const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024\n+\n+const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => {\n+  let total = 0\n+  const out: PasteSnippet[] = []\n+\n+  for (let i = snips.length - 1; i >= 0; i--) {\n+    const snip = snips[i]!\n+    const size = snip.text.length\n+\n+    if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) {\n+      break\n+    }\n+\n+    total += size\n+    out.unshift(snip)\n+  }\n+\n+  return out.length === snips.length ? snips : out\n+}\n+\n export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {\n   const [input, setInput] = useState('')\n   const [inputBuf, setInputBuf] = useState<string[]>([])","@@ -31,6 +53,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose\n   const clearIn = useCallback(() => {\n     setInput('')\n     setInputBuf([])\n+    setPasteSnips([])\n     setQueueEdit(null)\n     setHistoryIdx(null)\n     historyDraftRef.current = ''","@@ -68,7 +91,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose\n       const tail = cursor < value.length && !/\\s/.test(value[cursor] ?? '') ? ' ' : ''\n       const insert = `${lead}${label}${tail}`\n \n-      setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))\n+      setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }]))\n \n       void gw\n         .request<{ path?: string }>('paste.collapse', { text: cleanedText })"]},{"file":"ui-tui/src/app/useMainApp.ts","hunks":["@@ -448,6 +448,7 @@ export function useMainApp(gw: GatewayClient) {\n     const handler = (ev: GatewayEvent) => onEventRef.current(ev)\n \n     const exitHandler = () => {\n+      turnController.reset()\n       patchUiState({ busy: false, sid: null, status: 'gateway exited' })\n       turnController.pushActivity('gateway exited · /logs to inspect', 'error')\n       sys('error: gateway exited')"]},{"file":"ui-tui/src/gatewayClient.ts","hunks":["@@ -7,10 +7,15 @@ import { createInterface } from 'node:readline'\n import type { GatewayEvent } from './gatewayTypes.js'\n \n const MAX_GATEWAY_LOG_LINES = 200\n+const MAX_LOG_LINE_BYTES = 4096\n+const MAX_BUFFERED_EVENTS = 2000\n const MAX_LOG_PREVIEW = 240\n const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)\n const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)\n \n+const truncateLine = (line: string) =>\n+  line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line\n+\n const resolvePython = (root: string) => {\n   const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()\n ","@@ -69,7 +74,9 @@ export class GatewayClient extends EventEmitter {\n       return void this.emit('event', ev)\n     }\n \n-    this.bufferedEvents.push(ev)\n+    if (this.bufferedEvents.push(ev) > MAX_BUFFERED_EVENTS) {\n+      this.bufferedEvents.splice(0, this.bufferedEvents.length - MAX_BUFFERED_EVENTS)\n+    }\n   }\n \n   start() {","@@ -121,7 +128,7 @@ export class GatewayClient extends EventEmitter {\n \n     this.stderrRl = createInterface({ input: this.proc.stderr! })\n     this.stderrRl.on('line', raw => {\n-      const line = raw.trim()\n+      const line = truncateLine(raw.trim())\n \n       if (!line) {\n         return","@@ -181,7 +188,7 @@ export class GatewayClient extends EventEmitter {\n   }\n \n   private pushLog(line: string) {\n-    if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) {\n+    if (this.logs.push(truncateLine(line)) > MAX_GATEWAY_LOG_LINES) {\n       this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)\n     }\n   }\n"]}]},{"kind":"pr","number":12822,"title":"fix(tui): guard empty idle queue drain to stop idle OOM","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12888","number":12888},{"kind":"pr","value":"#12754","number":12754}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12888,"title":"fix(tui): guard empty idle queue drain to stop idle OOM","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","ui-tui/src/app/useMainApp.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}