{"repo":"NousResearch/hermes-agent","public_id":"21767","display_label":"TUI heap dump disk-fill risk and memory guards","summary":"These issues and pull requests address automatic heap dumps in the TUI filling disk space, proposing or implementing opt-in controls and retention limits, alongside a related change to bound memory pressure during live rendering.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41948,"merged_at":"2026-06-08T09:20:50+00:00"},"resolution":{"state":"resolved","by":"pr-41948"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T09:20:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":21767,"pr_number":41948,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-08T09:20:50+00:00"}],"members":[{"kind":"issue","number":21767,"title":"TUI automatic heap dumps can fill disk; add opt-in or retention guard","url":null,"state":"closed"},{"kind":"pr","number":21780,"title":"fix(tui): make automatic heap dumps opt-in to prevent disk-fill","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":21792,"title":"fix(tui): guard automatic heap dumps against disk fill","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8ae0d054f","number":0},{"kind":"pr","value":"#41948","number":41948}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":41948,"title":"fix(tui): guard automatic heap dumps against disk fill (#21767)","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/entry.tsx","ui-tui/src/lib/memory.test.ts","ui-tui/src/lib/memory.ts","ui-tui/src/lib/memoryMonitor.ts"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -65,6 +65,7 @@\n     \"129007007+HeLLGURD@users.noreply.github.com\": \"HeLLGURD\",\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"alarcritty\",\n     \"islam666@users.noreply.github.com\": \"islam666\",\n     \"25539605+lsaether@users.noreply.github.com\": \"lsaether\",\n     \"30080538+JimStenstrom@users.noreply.github.com\": \"JimStenstrom\","]},{"file":"ui-tui/src/entry.tsx","hunks":["@@ -37,7 +37,7 @@ const gw = new GatewayClient()\n gw.start()\n \n const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>\n-  `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\\n`\n+  `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\\n`\n \n setupGracefulExit({\n   cleanups: ["]},{"file":"ui-tui/src/lib/memory.test.ts","hunks":["@@ -0,0 +1,162 @@\n+import { mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'\n+import { tmpdir } from 'node:os'\n+import { join } from 'node:path'\n+\n+import { afterEach, beforeEach, describe, expect, it } from 'vitest'\n+\n+import { performHeapDump } from './memory.js'\n+\n+const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const\n+\n+describe('performHeapDump auto opt-in gate (#21767)', () => {\n+  let saved: Record<string, string | undefined>\n+  let dir: string\n+\n+  beforeEach(() => {\n+    saved = {}\n+\n+    for (const k of ENV_KEYS) {\n+      saved[k] = process.env[k]\n+      delete process.env[k]\n+    }\n+\n+    dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-test-'))\n+    process.env.HERMES_HEAPDUMP_DIR = dir\n+  })\n+\n+  afterEach(() => {\n+    for (const k of ENV_KEYS) {\n+      if (saved[k] === undefined) {\n+        delete process.env[k]\n+      } else {\n+        process.env[k] = saved[k]\n+      }\n+    }\n+\n+    rmSync(dir, { force: true, recursive: true })\n+  })\n+\n+  it('writes diagnostics only for auto-high without HERMES_AUTO_HEAPDUMP', async () => {\n+    const result = await performHeapDump('auto-high')\n+\n+    expect(result.success).toBe(true)\n+    expect(result.suppressed).toBe(true)\n+    expect(result.diagPath).toBeDefined()\n+    expect(result.heapPath).toBeUndefined()\n+\n+    const files = readdirSync(dir)\n+    expect(files.some(f => f.endsWith('.diagnostics.json'))).toBe(true)\n+    expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)\n+  })\n+\n+  it('writes diagnostics only for auto-critical without HERMES_AUTO_HEAPDUMP', async () => {\n+    const result = await performHeapDump('auto-critical')\n+\n+    expect(result.success).toBe(true)\n+    expect(result.suppressed).toBe(true)\n+    expect(result.heapPath).toBeUndefined()\n+\n+    const files = readdirSync(dir)\n+    expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)\n+  })\n+\n+  it('writes both diagnostics and snapshot for auto-high when HERMES_AUTO_HEAPDUMP=1', async () => {\n+    process.env.HERMES_AUTO_HEAPDUMP = '1'\n+\n+    const result = await performHeapDump('auto-high')\n+\n+    expect(result.success).toBe(true)\n+    expect(result.suppressed).toBeUndefined()\n+    expect(result.diagPath).toBeDefined()\n+    expect(result.heapPath).toBeDefined()\n+\n+    const files = readdirSync(dir)\n+    expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)\n+  })\n+\n+  it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => {\n+    for (const value of ['true', 'YES', 'On']) {\n+      process.env.HERMES_AUTO_HEAPDUMP = value\n+      const result = await performHeapDump('auto-high')\n+\n+      expect(result.success).toBe(true)\n+      expect(result.heapPath).toBeDefined()\n+    }\n+  })\n+\n+  it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => {\n+    for (const value of ['0', 'off', 'nope']) {\n+      process.env.HERMES_AUTO_HEAPDUMP = value\n+      const result = await performHeapDump('auto-high')\n+\n+      expect(result.success).toBe(true)\n+      expect(result.suppressed).toBe(true)\n+      expect(result.heapPath).toBeUndefined()\n+    }\n+  })\n+\n+  it('writes both for manual triggers regardless of HERMES_AUTO_HEAPDUMP', async () => {\n+    const result = await performHeapDump('manual')\n+\n+    expect(result.success).toBe(true)\n+    expect(result.suppressed).toBeUndefined()\n+    expect(result.heapPath).toBeDefined()\n+\n+    const files = readdirSync(dir)\n+    expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)\n+  })\n+})\n+\n+describe('heapdump retention guard (#21767)', () => {\n+  let savedDir: string | undefined\n+  let savedMax: string | undefined\n+  let dir: string\n+\n+  beforeEach(() => {\n+    savedDir = process.env.HERMES_HEAPDUMP_DIR\n+    savedMax = process.env.HERMES_HEAPDUMP_MAX_BYTES\n+    delete process.env.HERMES_AUTO_HEAPDUMP\n+    dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-prune-'))\n+    process.env.HERMES_HEAPDUMP_DIR = dir\n+  })\n+\n+  afterEach(() => {\n+    if (savedDir === undefined) {delete process.env.HERMES_HEAPDUMP_DIR}\n+    else {process.env.HERMES_HEAPDUMP_DIR = savedDir}\n+\n+    if (savedMax === undefined) {delete process.env.HERMES_HEAPDUMP_MAX_BYTES}\n+    else {process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax}\n+\n+    rmSync(dir, { force: true, recursive: true })\n+  })\n+\n+  it('evicts oldest files when total bytes exceed the cap, retaining the newest', async () => {\n+    // 4 pre-existing dumps, 1KB each, with ascending mtimes (oldest first).\n+    const blob = 'x'.repeat(1024)\n+    const now = Date.now()\n+\n+    for (let i = 0; i < 4; i++) {\n+      const p = join(dir, `old-${i}.heapsnapshot`)\n+      writeFileSync(p, blob)\n+      const t = (now - (4 - i) * 60_000) / 1000\n+      utimesSync(p, t, t)\n+    }\n+\n+    // Cap at 2KB → a fresh diagnostics write should trigger a prune down to ~cap.\n+    process.env.HERMES_HEAPDUMP_MAX_BYTES = String(2 * 1024)\n+\n+    const result = await performHeapDump('auto-high')\n+    expect(result.success).toBe(true)\n+\n+    const remaining = readdirSync(dir)\n+    const totalBytes = remaining.reduce((acc, f) => acc + statSync(join(dir, f)).size, 0)\n+    // Contract: prune evicts oldest-first until total <= cap, but always keeps\n+    // the single newest file even if it alone exceeds the cap. So either the\n+    // total is under cap, or exactly one (newest) file remains.\n+    expect(totalBytes <= 2 * 1024 || remaining.length === 1).toBe(true)\n+    // The old 1KB dumps must have been pruned down from the original four.\n+    expect(remaining.length).toBeLessThan(5)\n+    // The brand-new diagnostics sidecar must survive the prune.\n+    expect(remaining.some(f => f.endsWith('.diagnostics.json'))).toBe(true)\n+  })\n+})"]},{"file":"ui-tui/src/lib/memory.ts","hunks":["@@ -1,5 +1,5 @@\n import { createWriteStream } from 'node:fs'\n-import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'\n+import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'\n import { homedir, tmpdir } from 'node:os'\n import { join } from 'node:path'\n import { pipeline } from 'node:stream/promises'","@@ -51,6 +51,9 @@ export interface HeapDumpResult {\n   diagPath?: string\n   error?: string\n   heapPath?: string\n+  // True when an auto trigger wrote diagnostics only and intentionally skipped\n+  // the heavy snapshot because HERMES_AUTO_HEAPDUMP was not enabled (#21767).\n+  suppressed?: boolean\n   success: boolean\n }\n ","@@ -153,8 +156,26 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis\n     const heapPath = join(dir, `${base}.heapsnapshot`)\n     const diagPath = join(dir, `${base}.diagnostics.json`)\n \n+    // The diagnostics JSON is KB-sized and the most useful artifact when a\n+    // full snapshot is suppressed by the auto-heapdump opt-in gate below.\n     await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 })\n+\n+    // Auto triggers require explicit opt-in: multi-GiB snapshots written on\n+    // every threshold cross can fill the user's disk (issue #21767).\n+    const isAuto = trigger === 'auto-critical' || trigger === 'auto-high'\n+    const autoEnabled = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_AUTO_HEAPDUMP ?? '').trim())\n+\n+    if (isAuto && !autoEnabled) {\n+      await pruneHeapdumps(dir).catch(() => undefined)\n+\n+      // Not an error: the dump did its job — it wrote the lightweight\n+      // diagnostics sidecar and intentionally skipped the heavy snapshot.\n+      // `heapPath` is omitted so callers/notices report diagnostics-only.\n+      return { diagPath, suppressed: true, success: true }\n+    }\n+\n     await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 }))\n+    await pruneHeapdumps(dir).catch(() => undefined)\n \n     return { diagPath, heapPath, success: true }\n   } catch (e) {","@@ -162,6 +183,44 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis\n   }\n }\n \n+// Cap total bytes of files in `dir`, deleting oldest first. Covers both\n+// `.heapsnapshot` and `.diagnostics.json` artifacts so orphan sidecars from\n+// gated auto-triggers cannot accumulate without bound. The newest file is\n+// always retained even if it alone exceeds the cap.\n+async function pruneHeapdumps(dir: string): Promise<void> {\n+  const raw = process.env.HERMES_HEAPDUMP_MAX_BYTES?.trim()\n+  const parsed = raw ? Number(raw) : NaN\n+  const cap = Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 ** 3\n+\n+  const names = await readdir(dir)\n+\n+  const stats = await Promise.all(\n+    names.map(async name => {\n+      const path = join(dir, name)\n+      const s = await stat(path).catch(() => null)\n+\n+      return s && s.isFile() ? { mtimeMs: s.mtimeMs, path, size: s.size } : null\n+    })\n+  )\n+\n+  const valid = stats.filter((s): s is { mtimeMs: number; path: string; size: number } => s !== null)\n+\n+  valid.sort((a, b) => b.mtimeMs - a.mtimeMs)\n+\n+  let total = valid.reduce((acc, s) => acc + s.size, 0)\n+\n+  while (total > cap && valid.length > 1) {\n+    const oldest = valid.pop()\n+\n+    if (!oldest) {\n+      break\n+    }\n+\n+    await unlink(oldest.path).catch(() => undefined)\n+    total -= oldest.size\n+  }\n+}\n+\n export function formatBytes(bytes: number): string {\n   if (!Number.isFinite(bytes) || bytes <= 0) {\n     return '0B'"]},{"file":"ui-tui/src/lib/memoryMonitor.ts","hunks":["@@ -111,6 +111,14 @@ export function startMemoryMonitor({\n   let warned = false\n   const WARN_GROWTH_STEP = 150 * MB\n \n+  // Cooldown prevents repeated auto dumps when heap oscillates around the\n+  // threshold (issue #21767). `dumped` alone is not enough — it clears on\n+  // every transition back to `normal`.\n+  const cooldownRaw = process.env.HERMES_AUTO_HEAPDUMP_COOLDOWN_MS?.trim()\n+  const cooldownParsed = cooldownRaw ? Number(cooldownRaw) : NaN\n+  const cooldownMs = Number.isFinite(cooldownParsed) && cooldownParsed >= 0 ? cooldownParsed : 600_000\n+  let lastAutoDumpAt = 0\n+\n   const tick = async () => {\n     const { heapUsed, rss } = process.memoryUsage()\n ","@@ -137,7 +145,12 @@ export function startMemoryMonitor({\n       return\n     }\n \n+    if (Date.now() - lastAutoDumpAt < cooldownMs) {\n+      return\n+    }\n+\n     inFlight.add(level)\n+    lastAutoDumpAt = Date.now()\n \n     // Prune Ink content caches before dump/exit — half on 'high' (recoverable),\n     // full on 'critical' (post-dump RSS reduction, keeps user running).\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}