Files
hermes-agent/ui-tui/src/gatewayClient.ts

294 lines
8.0 KiB
TypeScript
Raw Normal View History

2026-04-02 19:34:30 -05:00
import { type ChildProcess, spawn } from 'node:child_process'
2026-04-02 19:06:42 -05:00
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
2026-04-13 21:20:55 -05:00
import { delimiter, resolve } from 'node:path'
2026-04-02 19:34:30 -05:00
import { createInterface } from 'node:readline'
2026-04-02 19:06:42 -05:00
2026-04-15 14:14:01 -05:00
import type { GatewayEvent } from './gatewayTypes.js'
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
import { CircularBuffer } from './lib/circularBuffer.js'
2026-04-15 14:14:01 -05:00
2026-04-06 18:38:13 -05:00
const MAX_GATEWAY_LOG_LINES = 200
const MAX_LOG_LINE_BYTES = 4096
const MAX_BUFFERED_EVENTS = 2000
2026-04-06 18:38:13 -05:00
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)
2026-04-06 18:38:13 -05:00
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) => {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
if (configured) {
return configured
}
const venv = process.env.VIRTUAL_ENV?.trim()
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const hit = [
venv && resolve(venv, 'bin/python'),
venv && resolve(venv, 'Scripts/python.exe'),
resolve(root, '.venv/bin/python'),
resolve(root, '.venv/bin/python3'),
resolve(root, 'venv/bin/python'),
resolve(root, 'venv/bin/python3')
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
].find(p => p && existsSync(p))
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
return hit || (process.platform === 'win32' ? 'python' : 'python3')
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const asGatewayEvent = (value: unknown): GatewayEvent | null =>
value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string'
? (value as GatewayEvent)
: null
2026-04-02 19:06:42 -05:00
interface Pending {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
id: string
method: string
2026-04-02 19:06:42 -05:00
reject: (e: Error) => void
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
resolve: (v: unknown) => void
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
timeout: ReturnType<typeof setTimeout>
2026-04-02 19:06:42 -05:00
}
export class GatewayClient extends EventEmitter {
private proc: ChildProcess | null = null
private reqId = 0
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
private logs = new CircularBuffer<string>(MAX_GATEWAY_LOG_LINES)
2026-04-02 19:06:42 -05:00
private pending = new Map<string, Pending>()
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
private bufferedEvents = new CircularBuffer<GatewayEvent>(MAX_BUFFERED_EVENTS)
private pendingExit: number | null | undefined
private ready = false
private readyTimer: ReturnType<typeof setTimeout> | null = null
2026-04-13 14:49:10 -05:00
private subscribed = false
private stdoutRl: ReturnType<typeof createInterface> | null = null
private stderrRl: ReturnType<typeof createInterface> | null = null
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
constructor() {
super()
// useInput / createGatewayEventHandler can legitimately attach many
// listeners. Default 10-cap triggers spurious warnings.
this.setMaxListeners(0)
}
private publish(ev: GatewayEvent) {
if (ev.type === 'gateway.ready') {
this.ready = true
if (this.readyTimer) {
clearTimeout(this.readyTimer)
this.readyTimer = null
}
}
if (this.subscribed) {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
return void this.emit('event', ev)
}
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
this.bufferedEvents.push(ev)
}
2026-04-02 19:06:42 -05:00
start() {
2026-04-11 15:58:22 -04:00
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
const python = resolvePython(root)
const cwd = process.env.HERMES_CWD || root
2026-04-13 21:20:55 -05:00
const env = { ...process.env }
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const pyPath = env.PYTHONPATH?.trim()
2026-04-13 21:20:55 -05:00
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.ready = false
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
this.bufferedEvents.clear()
this.pendingExit = undefined
this.stdoutRl?.close()
this.stderrRl?.close()
this.stdoutRl = null
this.stderrRl = null
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
this.proc.kill()
}
if (this.readyTimer) {
clearTimeout(this.readyTimer)
}
this.readyTimer = setTimeout(() => {
if (this.ready) {
return
}
2026-04-02 19:06:42 -05:00
this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`)
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
}, STARTUP_TIMEOUT_MS)
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
2026-04-02 19:06:42 -05:00
this.stdoutRl = createInterface({ input: this.proc.stdout! })
this.stdoutRl.on('line', raw => {
2026-04-02 19:34:30 -05:00
try {
this.dispatch(JSON.parse(raw))
} catch {
2026-04-06 18:38:13 -05:00
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
2026-04-06 18:38:13 -05:00
this.pushLog(`[protocol] malformed stdout: ${preview}`)
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.publish({ type: 'gateway.protocol_error', payload: { preview } })
2026-04-02 19:34:30 -05:00
}
2026-04-02 19:06:42 -05:00
})
this.stderrRl = createInterface({ input: this.proc.stderr! })
this.stderrRl.on('line', raw => {
const line = truncateLine(raw.trim())
2026-04-06 18:38:13 -05:00
if (!line) {
return
}
this.pushLog(line)
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.publish({ type: 'gateway.stderr', payload: { line } })
2026-04-06 18:38:13 -05:00
})
this.proc.on('error', err => {
this.pushLog(`[spawn] ${err.message}`)
this.rejectPending(new Error(`gateway error: ${err.message}`))
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } })
2026-04-06 18:38:13 -05:00
})
this.proc.on('exit', code => {
if (this.readyTimer) {
clearTimeout(this.readyTimer)
this.readyTimer = null
}
2026-04-06 18:38:13 -05:00
this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`))
if (this.subscribed) {
this.emit('exit', code)
} else {
this.pendingExit = code
}
2026-04-06 18:38:13 -05:00
})
2026-04-02 19:06:42 -05:00
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined
const p = id ? this.pending.get(id) : undefined
if (p) {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
this.settle(p, msg.error ? this.toError(msg.error) : null, msg.result)
2026-04-02 19:34:30 -05:00
2026-04-02 19:06:42 -05:00
return
}
2026-04-02 19:34:30 -05:00
if (msg.method === 'event') {
2026-04-15 14:14:01 -05:00
const ev = asGatewayEvent(msg.params)
if (ev) {
this.publish(ev)
}
2026-04-02 19:34:30 -05:00
}
2026-04-02 19:06:42 -05:00
}
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
private toError(raw: unknown): Error {
const err = raw as { message?: unknown } | null | undefined
return new Error(typeof err?.message === 'string' ? err.message : 'request failed')
}
private settle(p: Pending, err: Error | null, result: unknown) {
clearTimeout(p.timeout)
this.pending.delete(p.id)
if (err) {
p.reject(err)
} else {
p.resolve(result)
2026-04-06 18:38:13 -05:00
}
}
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
private pushLog(line: string) {
this.logs.push(truncateLine(line))
}
2026-04-06 18:38:13 -05:00
private rejectPending(err: Error) {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
for (const p of this.pending.values()) {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
clearTimeout(p.timeout)
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
p.reject(err)
2026-04-06 18:38:13 -05:00
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.pending.clear()
2026-04-06 18:38:13 -05:00
}
// Arrow class-field — stable identity, so `setTimeout(this.onTimeout, …, id)`
// doesn't allocate a bound function per request.
private onTimeout = (id: string) => {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
const p = this.pending.get(id)
if (p) {
this.pending.delete(id)
p.reject(new Error(`timeout: ${p.method}`))
}
}
2026-04-13 14:49:10 -05:00
drain() {
this.subscribed = true
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
for (const ev of this.bufferedEvents.drain()) {
2026-04-13 14:49:10 -05:00
this.emit('event', ev)
}
if (this.pendingExit !== undefined) {
const code = this.pendingExit
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
this.pendingExit = undefined
this.emit('exit', code)
}
2026-04-13 14:49:10 -05:00
}
2026-04-06 18:38:13 -05:00
getLogTail(limit = 20): string {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
return this.logs.tail(Math.max(1, limit)).join('\n')
2026-04-06 18:38:13 -05:00
}
2026-04-15 14:14:01 -05:00
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) {
this.start()
}
2026-04-06 18:38:13 -05:00
if (!this.proc?.stdin) {
return Promise.reject(new Error('gateway not running'))
}
2026-04-02 19:06:42 -05:00
2026-04-06 18:38:13 -05:00
const id = `r${++this.reqId}`
2026-04-02 19:06:42 -05:00
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(this.onTimeout, REQUEST_TIMEOUT_MS, id)
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
timeout.unref?.()
2026-04-06 18:38:13 -05:00
this.pending.set(id, {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
id,
method,
reject,
resolve: v => resolve(v as T),
timeout
2026-04-06 18:38:13 -05:00
})
try {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
this.proc!.stdin!.write(JSON.stringify({ id, jsonrpc: '2.0', method, params }) + '\n')
2026-04-06 18:38:13 -05:00
} catch (e) {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
const pending = this.pending.get(id)
if (pending) {
clearTimeout(pending.timeout)
this.pending.delete(id)
}
2026-04-06 18:38:13 -05:00
reject(e instanceof Error ? e : new Error(String(e)))
}
2026-04-02 19:06:42 -05:00
})
}
2026-04-02 19:34:30 -05:00
kill() {
this.proc?.kill()
}
2026-04-02 19:06:42 -05:00
}