mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): HERMES_TUI_DIAGNOSTICS master switch — gate /mem, /heapdump + window-stats default
Regular users get zero diagnostic surface by default: /mem and /heapdump disappear from /help and completion, and invoking them prints the one-line enable hint (relaunch with HERMES_TUI_DIAGNOSTICS=1) instead of executing — an enable switch, not a secret. With the switch on, the commands work as before and HERMES_TUI_WINDOW_STATS defaults on (still individually settable either way). Full env-flag ledger (master switch / user config / dev tuning / internal plumbing) in docs/opentui-env-flags.md. 672 tests exit 0.
This commit is contained in:
parent
fc8d5f203a
commit
cf3002664b
6 changed files with 152 additions and 10 deletions
57
docs/opentui-env-flags.md
Normal file
57
docs/opentui-env-flags.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# OpenTUI env flags — the consolidated ledger
|
||||
|
||||
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
|
||||
classified by who should ever touch it. The design rule shipped with this doc:
|
||||
**regular users see zero diagnostic surface by default; one master switch
|
||||
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
|
||||
|
||||
## 1. The master switch
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`) — hidden from `/help` and completion otherwise, and invoking them while off prints the enable hint rather than executing. Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
|
||||
|
||||
## 2. User-facing configuration (fine to document publicly)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
|
||||
| `HERMES_TUI_MOUSE` | on (launcher sets it) | Mouse support (wheel scroll, selection, click-to-expand). |
|
||||
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
|
||||
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
|
||||
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
|
||||
|
||||
## 3. Escape hatches & tuning (dev-facing, individually settable)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
|
||||
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
|
||||
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for bench/live-attach reads. |
|
||||
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
|
||||
|
||||
## 4. Internal plumbing (set by the launcher/bench/tests — humans never set these)
|
||||
|
||||
| var | set by | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
|
||||
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
|
||||
| `HERMES_TUI_RESUME`, `HERMES_TUI_PROMPT`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot, seeded prompt, fake-mode. |
|
||||
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
|
||||
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
|
||||
|
||||
## How the pieces compose (the support script)
|
||||
|
||||
- Regular user, normal day: zero flags, zero diagnostic commands visible.
|
||||
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem`
|
||||
for the live numbers, `/heapdump` for a snapshot to attach, window stats
|
||||
exposed for `bench/live-attach.sh <pid>` to read.
|
||||
- Developer profiling: same master switch + the individual knobs
|
||||
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
|
||||
- Anything in section 4 appearing in a user-facing doc is a bug.
|
||||
|
||||
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
|
||||
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
|
||||
filtering), `view/transcript.tsx` (stats default). Tests:
|
||||
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
|
||||
themselves, gate enabled suite-wide).
|
||||
|
|
@ -15,6 +15,21 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
|
|||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* The diagnostics master switch — `HERMES_TUI_DIAGNOSTICS` (default OFF).
|
||||
*
|
||||
* Gates the developer/profiling surface a regular user should never trip
|
||||
* over: the diagnostic slash commands (`/mem`, `/heapdump`) and the default
|
||||
* for `HERMES_TUI_WINDOW_STATS` (which can still be set individually). It is
|
||||
* an enable switch, not a secret: anyone CAN set it (support flows say
|
||||
* "relaunch with HERMES_TUI_DIAGNOSTICS=1"), it just keeps the day-to-day
|
||||
* surface clean. Read per call so tests (and long-lived processes whose
|
||||
* wrapper mutates env before launch) see the live value.
|
||||
*/
|
||||
export function diagnosticsEnabled(): boolean {
|
||||
return envFlag(process.env.HERMES_TUI_DIAGNOSTICS, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var — deliberately NOT
|
||||
* a config.yaml knob): how many output lines an expanded tool body shows.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||
* prefill → notice). Long output routes to the pager (Phase 5a).
|
||||
*/
|
||||
import { diagnosticsEnabled } from './env.ts'
|
||||
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
|
||||
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
|
||||
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
|
||||
|
|
@ -149,7 +150,12 @@ function present(ctx: SlashContext, title: string, text: string): void {
|
|||
else ctx.pushSystem(text)
|
||||
}
|
||||
|
||||
const CLIENT_HELP = [
|
||||
/** Process-diagnostic commands — hidden behind `HERMES_TUI_DIAGNOSTICS`
|
||||
* (logic/env.ts). Regular users never see them; support flows enable them
|
||||
* with one env var. Keep this set in sync with the `(diag)` lines below. */
|
||||
const DIAGNOSTIC_COMMANDS = new Set(['mem', 'heapdump'])
|
||||
|
||||
const CLIENT_HELP_LINES = [
|
||||
'/help — list commands',
|
||||
'/model [name] — switch model (picker if bare)',
|
||||
'/copy [n] — copy the last (or n-th) response',
|
||||
|
|
@ -160,12 +166,17 @@ const CLIENT_HELP = [
|
|||
'/compact [on|off|toggle] — compact transcript spacing',
|
||||
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
|
||||
'/replay [n|path] — inspect an archived spawn tree',
|
||||
'/mem — live memory stats',
|
||||
'/heapdump — write a V8 heap snapshot',
|
||||
'/mem — live memory stats (diag)',
|
||||
'/heapdump — write a V8 heap snapshot (diag)',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
].join('\n')
|
||||
]
|
||||
|
||||
function clientHelp(): string {
|
||||
const lines = diagnosticsEnabled() ? CLIENT_HELP_LINES : CLIENT_HELP_LINES.filter(l => !l.includes('(diag)'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
|
|
@ -631,9 +642,9 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||
try {
|
||||
const cat = await ctx.request('commands.catalog', {})
|
||||
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
|
||||
ctx.pushSystem(renderCatalog(cat) || clientHelp())
|
||||
} catch {
|
||||
ctx.pushSystem(CLIENT_HELP)
|
||||
ctx.pushSystem(clientHelp())
|
||||
}
|
||||
},
|
||||
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
|
||||
|
|
@ -643,7 +654,8 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
|
||||
/** The registered client-command names (catalog introspection — tests/menus). */
|
||||
export function clientCommandNames(): string[] {
|
||||
return Object.keys(CLIENT).sort()
|
||||
const names = Object.keys(CLIENT)
|
||||
return (diagnosticsEnabled() ? names : names.filter(n => !DIAGNOSTIC_COMMANDS.has(n))).sort()
|
||||
}
|
||||
|
||||
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
||||
|
|
@ -701,6 +713,12 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<v
|
|||
const parsed = parseSlash(input)
|
||||
if (!parsed) return
|
||||
|
||||
if (DIAGNOSTIC_COMMANDS.has(parsed.name) && !diagnosticsEnabled()) {
|
||||
// Not a secret — an enable switch. Tell the user exactly how to get it.
|
||||
ctx.pushSystem(`/${parsed.name} is a diagnostic command — relaunch with HERMES_TUI_DIAGNOSTICS=1 to enable it.`)
|
||||
return
|
||||
}
|
||||
|
||||
const client = CLIENT[parsed.name]
|
||||
if (client) {
|
||||
await client(parsed.arg, ctx)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from 'vitest'
|
|||
import type { DetailsMode } from '../logic/details.ts'
|
||||
import {
|
||||
buildModelTabs,
|
||||
clientCommandNames,
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
parseSlash,
|
||||
|
|
@ -566,3 +567,40 @@ describe('dispatchSlash — server ladder', () => {
|
|||
expect(p.system).toContain('done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('diagnostic command gating (HERMES_TUI_DIAGNOSTICS)', () => {
|
||||
const KEY = 'HERMES_TUI_DIAGNOSTICS'
|
||||
const prev = process.env[KEY]
|
||||
afterEach(() => {
|
||||
if (prev === undefined) delete process.env[KEY]
|
||||
else process.env[KEY] = prev
|
||||
})
|
||||
|
||||
test('OFF (default): /mem and /heapdump respond with the enable hint, not the command', async () => {
|
||||
delete process.env[KEY]
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/mem', p.ctx)
|
||||
await dispatchSlash('/heapdump', p.ctx)
|
||||
expect(p.system[0]).toContain('HERMES_TUI_DIAGNOSTICS=1')
|
||||
expect(p.system[1]).toContain('HERMES_TUI_DIAGNOSTICS=1')
|
||||
expect(p.calls).toHaveLength(0) // never reached the gateway ladder either
|
||||
})
|
||||
|
||||
test('OFF: the diagnostic names are absent from clientCommandNames()', () => {
|
||||
delete process.env[KEY]
|
||||
const names = clientCommandNames()
|
||||
expect(names).not.toContain('mem')
|
||||
expect(names).not.toContain('heapdump')
|
||||
expect(names).toContain('logs') // non-diagnostic neighbors stay
|
||||
})
|
||||
|
||||
test('ON: /mem executes (live memory stats), names are listed', async () => {
|
||||
process.env[KEY] = '1'
|
||||
expect(clientCommandNames()).toContain('mem')
|
||||
expect(clientCommandNames()).toContain('heapdump')
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/mem', p.ctx)
|
||||
const out = [...p.system, ...p.paged.map(x => x.text)].join('\n')
|
||||
expect(out).toMatch(/rss|heap/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from '../l
|
|||
import { clientCommandNames, dispatchSlash, type SlashContext } from '../logic/slash.ts'
|
||||
import type { Part } from '../logic/store.ts'
|
||||
|
||||
// The utility commands under test are DIAGNOSTIC commands — gated behind
|
||||
// HERMES_TUI_DIAGNOSTICS (logic/env.ts). This suite tests the commands
|
||||
// themselves, so enable the gate for the whole file (gating behavior has its
|
||||
// own tests in slash.test.ts).
|
||||
const PREV_DIAG = process.env.HERMES_TUI_DIAGNOSTICS
|
||||
beforeEach(() => {
|
||||
process.env.HERMES_TUI_DIAGNOSTICS = '1'
|
||||
})
|
||||
afterEach(() => {
|
||||
if (PREV_DIAG === undefined) delete process.env.HERMES_TUI_DIAGNOSTICS
|
||||
else process.env.HERMES_TUI_DIAGNOSTICS = PREV_DIAG
|
||||
})
|
||||
|
||||
// /heapdump must not write a REAL multi-MB snapshot per test run — stub the V8
|
||||
// seam; the path/mkdir plumbing still runs for real (under a temp HERMES_HOME).
|
||||
vi.mock('node:v8', () => ({ writeHeapSnapshot: vi.fn((path?: string) => path ?? 'unnamed.heapsnapshot') }))
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core'
|
|||
import { useRenderer } from '@opentui/solid'
|
||||
import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
|
||||
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import { diagnosticsEnabled, envFlag } from '../logic/env.ts'
|
||||
import type { Message, SessionStore } from '../logic/store.ts'
|
||||
import {
|
||||
computeWindow,
|
||||
|
|
@ -234,9 +234,10 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
if (!streaming) estimates.set(key, estimate)
|
||||
return estimate
|
||||
}
|
||||
// DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS): the live
|
||||
// DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS — defaults to
|
||||
// the HERMES_TUI_DIAGNOSTICS master switch; settable individually): the live
|
||||
// current/peak mounted-row counters from logic/window.ts.
|
||||
if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, false)) {
|
||||
if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, diagnosticsEnabled())) {
|
||||
;(globalThis as unknown as Record<string, unknown>)['__hermesTuiWindowStats'] = windowRowStats()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue