opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
@@ -260,71 +260,3 @@ describe('StatusRule credits notice render priority', () => {
expect(textContent(element)).toContain('opus 4.8')
})
})
describe('StatusRule idle-since read-out', () => {
// The IdleSince component uses hooks, so it can't be invoked outside a
// renderer — assert on the element tree instead (same reason the duration
// tests don't check SessionDuration's text).
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
if (node === null || node === undefined || typeof node === 'boolean') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponentByName(child, name)
if (found) {
return found
}
}
return null
}
if (!React.isValidElement(node)) {
return null
}
if (typeof node.type === 'function' && node.type.name === name) {
return node
}
return findComponentByName(node.props.children, name)
}
it('shows time since the last final agent response when idle', () => {
const endedAt = Date.now() - 42_000
const element = StatusRule({
...baseProps,
lastTurnEndedAt: endedAt,
sessionStartedAt: Date.now() - 60_000
})
const idle = findComponentByName(element, 'IdleSince')
expect(idle).not.toBeNull()
expect(idle!.props.endedAt).toBe(endedAt)
})
it('is hidden while a turn is busy', () => {
const element = StatusRule({
...baseProps,
busy: true,
lastTurnEndedAt: Date.now() - 42_000,
turnStartedAt: Date.now()
})
expect(findComponentByName(element, 'IdleSince')).toBeNull()
})
it('is hidden before the first turn completes', () => {
const element = StatusRule({
...baseProps,
lastTurnEndedAt: null,
sessionStartedAt: Date.now() - 60_000
})
expect(findComponentByName(element, 'IdleSince')).toBeNull()
})
})
@@ -47,15 +47,4 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
})
it('respects a reduced option set when permanent allow is disabled', () => {
// tirith content-security warning present → no "always"; the 3-item set is
// once/session/deny, so 3 maps to deny and 4 is out of range.
const opts = ['once', 'session', 'deny'] as const
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('4', {}, 0, opts)).toEqual({ kind: 'noop' })
expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' })
expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' })
})
})
@@ -658,17 +658,6 @@ describe('createGatewayEventHandler', () => {
})
})
it('does not fetch config while constructing the gateway event handler', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.rpc = vi.fn(async () => null)
createGatewayEventHandler(ctx)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
@@ -869,29 +858,6 @@ describe('createGatewayEventHandler', () => {
])
})
it('defaults approval overlays to allowPermanent when the backend omits the field', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' }, type: 'approval.request' } as any)
expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })
})
it('preserves allow_permanent=false on approval overlays (tirith warning)', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({
payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' },
type: 'approval.request'
} as any)
expect(getOverlayState().approval).toMatchObject({
allowPermanent: false,
command: 'curl suspicious | bash',
description: 'content-security warning'
})
})
it('still surfaces terminal turn failures as errors', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
@@ -1054,9 +1020,8 @@ describe('createGatewayEventHandler', () => {
)
const onEvent = createGatewayEventHandler(ctx)
// Config fetch starts once the gateway is ready; let it resolve before any
// spawn (mirrors real usage — config lands well before first delegation).
onEvent({ payload: {}, type: 'gateway.ready' } as any)
// Eager config fetch fires at creation; let it resolve before any spawn
// (mirrors real usage — config lands well before the first delegation).
await Promise.resolve()
await Promise.resolve()
@@ -108,7 +108,6 @@ describe('createSlashHandler', () => {
expect(createSlashHandler(ctx)('/model x-model')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'x-model'
@@ -129,7 +128,6 @@ describe('createSlashHandler', () => {
createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)
).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'anthropic/claude-sonnet-4.6 --provider openrouter'
@@ -142,7 +140,6 @@ describe('createSlashHandler', () => {
createSlashHandler(ctx)('/model x-model --global')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'x-model --global'
+103 -84
View File
@@ -1,103 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GatewayClient } from '../gatewayClient.js'
interface ListenerEntry {
callback: (event: any) => void
once: boolean
}
const { FakeWebSocket } = vi.hoisted(() => {
class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: FakeWebSocket[] = []
class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: FakeWebSocket[] = []
readyState = FakeWebSocket.CONNECTING
sent: string[] = []
readonly url: string
private listeners = new Map<string, ListenerEntry[]>()
readyState = FakeWebSocket.CONNECTING
sent: string[] = []
readonly url: string
private listeners = new Map<string, ListenerEntry[]>()
constructor(url: string) {
this.url = url
FakeWebSocket.instances.push(this)
constructor(url: string) {
this.url = url
FakeWebSocket.instances.push(this)
}
static reset() {
FakeWebSocket.instances = []
}
addEventListener(type: string, callback: (event: any) => void, options?: unknown) {
const once =
typeof options === 'object' &&
options !== null &&
'once' in options &&
Boolean((options as { once?: unknown }).once)
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once })
this.listeners.set(type, entries)
}
removeEventListener(type: string, callback: (event: any) => void) {
const entries = this.listeners.get(type)
if (!entries) {
return
}
static reset() {
FakeWebSocket.instances = []
this.listeners.set(
type,
entries.filter(entry => entry.callback !== callback)
)
}
send(payload: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error('socket not open')
}
addEventListener(type: string, callback: (event: any) => void, options?: unknown) {
const once =
typeof options === 'object' &&
options !== null &&
'once' in options &&
Boolean((options as { once?: unknown }).once)
this.sent.push(payload)
}
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once })
this.listeners.set(type, entries)
close(code = 1000) {
if (this.readyState === FakeWebSocket.CLOSED) {
return
}
removeEventListener(type: string, callback: (event: any) => void) {
const entries = this.listeners.get(type)
this.readyState = FakeWebSocket.CLOSED
this.emit('close', { code })
}
if (!entries) {
return
}
open() {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
}
this.listeners.set(
type,
entries.filter(entry => entry.callback !== callback)
)
}
message(data: string) {
this.emit('message', { data })
}
send(payload: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error('socket not open')
}
private emit(type: string, event: any) {
const entries = [...(this.listeners.get(type) ?? [])]
this.sent.push(payload)
}
for (const entry of entries) {
entry.callback(event)
close(code = 1000) {
if (this.readyState === FakeWebSocket.CLOSED) {
return
}
this.readyState = FakeWebSocket.CLOSED
this.emit('close', { code })
}
open() {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
}
message(data: string) {
this.emit('message', { data })
}
private emit(type: string, event: any) {
const entries = [...(this.listeners.get(type) ?? [])]
for (const entry of entries) {
entry.callback(event)
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
}
}
return { FakeWebSocket }
})
vi.mock('undici', () => ({ WebSocket: FakeWebSocket }))
import { GatewayClient } from '../gatewayClient.js'
}
describe('GatewayClient websocket attach mode', () => {
const originalWebSocket = globalThis.WebSocket
@@ -275,15 +269,30 @@ describe('GatewayClient websocket attach mode', () => {
gw.kill()
})
it('uses the undici WebSocket fallback when global WebSocket is unavailable', () => {
it('redacts query string secrets in attach failure logs and events', () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=hunter2&channel=secret'
delete (globalThis as { WebSocket?: unknown }).WebSocket
const gw = new GatewayClient()
const stderrLines: string[] = []
gw.on('event', ev => {
if (ev.type === 'gateway.stderr' && typeof ev.payload?.line === 'string') {
stderrLines.push(ev.payload.line)
}
})
gw.start()
expect(FakeWebSocket.instances).toHaveLength(1)
expect(FakeWebSocket.instances[0]?.url).toBe('ws://gateway.test/api/ws?token=hunter2&channel=secret')
gw.drain()
expect(stderrLines.length).toBeGreaterThan(0)
for (const line of stderrLines) {
expect(line).not.toContain('hunter2')
expect(line).not.toContain('channel=secret')
}
expect(gw.getLogTail(20)).not.toContain('hunter2')
expect(gw.getLogTail(20)).not.toContain('channel=secret')
gw.kill()
})
@@ -354,17 +363,27 @@ describe('GatewayClient websocket attach mode', () => {
expect(() => new URL(fixture)).toThrow()
process.env.HERMES_TUI_GATEWAY_URL = fixture
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket {
constructor(url: string) {
throw new TypeError(`Invalid URL: ${url}`)
}
} as unknown as typeof WebSocket
delete (globalThis as { WebSocket?: unknown }).WebSocket
const gw = new GatewayClient()
const stderrLines: string[] = []
gw.on('event', ev => {
if (ev.type === 'gateway.stderr' && typeof ev.payload?.line === 'string') {
stderrLines.push(ev.payload.line)
}
})
gw.start()
gw.drain()
expect(stderrLines.length).toBeGreaterThan(0)
for (const line of stderrLines) {
expect(line).not.toContain('alice')
expect(line).not.toContain('hunter2')
expect(line).not.toContain('token=secret')
}
const tail = gw.getLogTail(20)
expect(tail).not.toContain('alice')
expect(tail).not.toContain('hunter2')
+1 -41
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
describe('shortCwd', () => {
const origHome = process.env.HOME
@@ -68,43 +68,3 @@ describe('fmtCwdBranch', () => {
expect(out).toContain(')')
})
})
describe('composeTabTitle', () => {
it('joins marker, name, model, and cwd in order', () => {
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
})
it('glues the marker to the first segment with a space, not a separator', () => {
expect(composeTabTitle('⏳', 'my session', 'opus-4', '~/proj').startsWith('⏳ my session')).toBe(true)
})
it('omits the session name when empty (matches the pre-name format)', () => {
expect(composeTabTitle('✓', '', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
})
it('treats a whitespace-only name as absent', () => {
expect(composeTabTitle('✓', ' ', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
})
it('omits the cwd when empty', () => {
expect(composeTabTitle('✓', 'my session', 'opus-4', '')).toBe('✓ my session · opus-4')
})
it('falls back to just the marker when only the marker is present', () => {
expect(composeTabTitle('✓', '', '', '')).toBe('✓')
})
it('truncates an over-long session name with an ellipsis', () => {
const long = 'a'.repeat(40)
const out = composeTabTitle('✓', long, 'opus-4', '', 28)
const namePart = out.slice('✓ '.length).split(' · ')[0]
expect(namePart.endsWith('…')).toBe(true)
expect(namePart.length).toBe(28)
})
it('keeps a name at the boundary length intact', () => {
const name = 'b'.repeat(28)
const out = composeTabTitle('✓', name, 'opus-4', '', 28)
expect(out).toBe(`${name} · opus-4`)
})
})
@@ -36,26 +36,4 @@ describe('terminal mode reset', () => {
expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false)
expect(write).not.toHaveBeenCalled()
})
// entry.tsx installs `process.on('exit', () => resetTerminalModes())` as the
// final backstop (#28419): /quit, Ctrl+C, Ctrl+D and any process.exit() path
// must disarm DEC mouse tracking so the parent shell / next TUI doesn't read
// leaked mouse reports as keystrokes. 'exit' handlers run synchronously only,
// so the reset must complete via a single synchronous write — verify that an
// exit-style invocation disables every SGR mouse mode that produced the
// reported `…;…M` garbage.
it('disarms mouse tracking from a synchronous exit-style handler', () => {
const write = vi.fn()
const stream = { isTTY: true, write } as unknown as NodeJS.WriteStream
// Mirror entry.tsx's process.on('exit') callback.
const onExit = () => resetTerminalModes(stream)
onExit()
expect(write).toHaveBeenCalledTimes(1)
const written = write.mock.calls[0]?.[0] as string
for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) {
expect(written).toContain(mode)
}
})
})
+6 -11
View File
@@ -219,6 +219,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
agentsNudgedThisTurn = false
}
// Kick off the config fetch eagerly at handler creation so the flag is
// resolved well before the first delegation of any real session (which
// only happens after gateway.ready + a user turn).
ensureAgentsNudgeConfig()
const refreshDelegationStatus = (force = false) => {
const now = Date.now()
@@ -307,12 +312,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
applySkin(skin)
}
// Kick off the config fetch once the gateway is actually ready. If handler
// construction does this during React render, a startup transport error can
// report through sys(), mutate transcript state, and trip React's
// "too many re-renders" guard in embedded dashboard PTYs.
ensureAgentsNudgeConfig()
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
@@ -729,12 +728,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'approval.request': {
const description = String(ev.payload.description ?? 'dangerous command')
// Only an explicit false (tirith warning) drops the permanent-allow option.
const allowPermanent = ev.payload.allow_permanent !== false
patchOverlayState({
approval: { allowPermanent, command: String(ev.payload.command ?? ''), description }
})
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
setStatus('approval needed')
return
-3
View File
@@ -93,7 +93,6 @@ export interface OverlayState {
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
pluginsHub: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
@@ -128,7 +127,6 @@ export interface UiState {
pasteCollapseChars: number
sections: SectionVisibility
sessionTitle: string
showCost: boolean
showReasoning: boolean
indicatorStyle: IndicatorStyle
@@ -368,7 +366,6 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
lastTurnEndedAt: null | number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string
+2 -6
View File
@@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({
confirm: null,
modelPicker: false,
pager: null,
pluginsHub: false,
secret: null,
sessions: false,
skillsHub: false,
@@ -21,10 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
Boolean(
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
)
({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()
@@ -49,7 +46,6 @@ export const resetFlowOverlays = () =>
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
pluginsHub: $overlayState.get().pluginsHub,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub
})
-28
View File
@@ -652,34 +652,6 @@ export const opsCommands: SlashCommand[] = [
}
},
{
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
name: 'plugins',
run: (arg, ctx, cmd) => {
// No argument → open the interactive Plugins Hub overlay. Any
// subcommand (enable/disable/list/install/…) falls through to the
// text slash worker so it stays at parity with `hermes plugins`.
if (!arg.trim()) {
return patchOverlayState({ pluginsHub: true })
}
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/plugins: no output'
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(text, 'Plugins') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',
+2 -19
View File
@@ -72,25 +72,10 @@ export const sessionCommands: SlashCommand[] = [
return patchOverlayState({ modelPicker: true })
}
const switchModel = (confirmExpensiveModel = false) => ctx.gateway
.rpc<ConfigSetResponse>('config.set', { confirm_expensive_model: confirmExpensiveModel, key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.confirm_required) {
patchOverlayState({
confirm: {
cancelLabel: 'Cancel',
confirmLabel: 'Switch anyway',
danger: true,
detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
onConfirm: () => switchModel(true),
title: 'Expensive model selection'
}
})
return
}
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
@@ -104,8 +89,6 @@ export const sessionCommands: SlashCommand[] = [
}))
})
)
switchModel()
}
},
-1
View File
@@ -22,7 +22,6 @@ const buildUiState = (): UiState => ({
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
sessionTitle: '',
showCost: false,
showReasoning: false,
sid: null,
-4
View File
@@ -151,10 +151,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ skillsHub: false })
}
if (overlay.pluginsHub) {
return patchOverlayState({ pluginsHub: false })
}
if (overlay.sessions) {
return patchOverlayState({ sessions: false })
}
+7 -27
View File
@@ -7,7 +7,7 @@ import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
ClarifyRespondResponse,
@@ -173,7 +173,6 @@ export function useMainApp(gw: GatewayClient) {
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
const [goodVibesTick, setGoodVibesTick] = useState(0)
const [bellOnComplete, setBellOnComplete] = useState(false)
@@ -501,14 +500,10 @@ export function useMainApp(gw: GatewayClient) {
useEffect(() => {
if (ui.busy) {
setTurnStartedAt(prev => prev ?? Date.now())
} else if (turnStartedAt != null) {
// Only stamp the idle marker when a turn was actually live — busy is
// also false on mount and we don't want a phantom "done" timestamp
// before the first turn has completed.
setLastTurnEndedAt(Date.now())
} else {
setTurnStartedAt(null)
}
}, [ui.busy, turnStartedAt])
}, [ui.busy])
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
@@ -529,22 +524,12 @@ export function useMainApp(gw: GatewayClient) {
if (!stopped && result?.sessions) {
const liveSessionCount = result.sessions.length
// Surface the current session's (auto-)title for the terminal
// titlebar. The active_list poll already carries it, so no extra
// round-trip is needed.
const currentSid = getUiState().sid
const sessionTitle =
result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
// Only patch when something actually changed. patchUiState always
// Only patch when the count actually changed. patchUiState always
// produces a new state object, which notifies every $uiState
// subscriber; patching unconditionally on each 1.5s poll re-renders
// the whole TUI and causes idle flicker.
const prev = getUiState()
if (prev.liveSessionCount !== liveSessionCount || prev.sessionTitle !== sessionTitle) {
patchUiState({ liveSessionCount, sessionTitle })
if (getUiState().liveSessionCount !== liveSessionCount) {
patchUiState({ liveSessionCount })
}
}
})
@@ -561,16 +546,13 @@ export function useMainApp(gw: GatewayClient) {
}, [gw, ui.sid])
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
// Format: `<marker> <session name> · <model> · <cwd>` — name/cwd omitted when absent.
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
const tabCwd = ui.info?.cwd
useTerminalTitle(
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
)
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
useEffect(() => {
if (!ui.sid || !stdout) {
@@ -1095,7 +1077,6 @@ export function useMainApp(gw: GatewayClient) {
// essentials and truncates this further on narrow terminals.
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
goodVibesTick,
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
sessionStartedAt: ui.sid ? sessionStartedAt : null,
showStickyPrompt: !!stickyPrompt,
statusColor: statusColorOf(ui.status, ui.theme.color),
@@ -1109,7 +1090,6 @@ export function useMainApp(gw: GatewayClient) {
cwd,
gitBranch,
goodVibesTick,
lastTurnEndedAt,
sessionStartedAt,
stickyPrompt,
turnStartedAt,
-27
View File
@@ -341,21 +341,6 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
return fmtDuration(now - startedAt)
}
function IdleSince({ endedAt }: { endedAt: number }) {
// Time since the last final agent response. Re-ticks every second like
// SessionDuration so the read-out stays live while the session idles.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [endedAt])
return `${fmtDuration(now - endedAt)}`
}
const effortLabel = (effort?: string) => {
const value = String(effort ?? '')
.trim()
@@ -415,7 +400,6 @@ export function StatusRule({
notice,
usage,
bgCount,
lastTurnEndedAt,
liveSessionCount,
sessionStartedAt,
showCost,
@@ -504,10 +488,6 @@ export function StatusRule({
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
// Idle clock — time since the last final agent response. Hidden while busy
// (the FaceTicker's elapsed tail covers the live turn) and before the first
// turn completes. Shares the duration breakpoint and width reservation.
const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
@@ -587,12 +567,6 @@ export function StatusRule({
<SessionDuration startedAt={sessionStartedAt!} />
</Text>
) : null}
{showIdle ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<IdleSince endedAt={lastTurnEndedAt!} />
</Text>
) : null}
{showCompressions ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
@@ -751,7 +725,6 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
interface StatusRuleProps {
bgCount: number
lastTurnEndedAt?: null | number
liveSessionCount: number
busy: boolean
cols: number
-1
View File
@@ -366,7 +366,6 @@ const StatusRulePane = memo(function StatusRulePane({
cols={composer.cols}
cwdLabel={status.cwdLabel}
indicatorStyle={ui.indicatorStyle}
lastTurnEndedAt={status.lastTurnEndedAt}
liveSessionCount={ui.liveSessionCount}
model={ui.info?.model ?? ''}
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
-8
View File
@@ -11,7 +11,6 @@ import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
@@ -126,7 +125,6 @@ export function FloatingOverlays({
overlay.pager ||
overlay.sessions ||
overlay.skillsHub ||
overlay.pluginsHub ||
completions.length
if (!hasAny) {
@@ -176,12 +174,6 @@ export function FloatingOverlays({
</FloatBox>
)}
{overlay.pluginsHub && (
<FloatBox color={theme.color.border}>
<PluginsHub gw={gw} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
-6
View File
@@ -254,12 +254,6 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : s.disabled || s.status === 'disabled' ? (
<Text color={t.color.muted}>disabled</Text>
) : s.status === 'connecting' ? (
<Text color={t.color.warn}>connecting</Text>
) : s.status === 'configured' ? (
<Text color={t.color.muted}>configured</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
-238
View File
@@ -1,238 +0,0 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
const VISIBLE = 12
const MIN_WIDTH = 44
const MAX_WIDTH = 96
interface PluginRow {
description?: string
name: string
source?: string
status?: string
version?: string
}
interface PluginsListResponse {
bundled_count?: number
plugins?: PluginRow[]
user_count?: number
}
interface PluginsToggleResponse {
name?: string
ok?: boolean
plugin?: PluginRow
unchanged?: boolean
}
type Scope = 'all' | 'user'
const GLYPH: Record<string, string> = {
disabled: '✗',
enabled: '✓'
}
export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
const [rows, setRows] = useState<PluginRow[]>([])
const [bundledCount, setBundledCount] = useState(0)
const [userCount, setUserCount] = useState(0)
const [idx, setIdx] = useState(0)
const [scope, setScope] = useState<Scope>('user')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const load = () => {
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })
.then(r => {
setRows(r?.plugins ?? [])
setUserCount(Number(r?.user_count ?? 0))
setBundledCount(Number(r?.bundled_count ?? 0))
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}
useEffect(load, [gw])
// Default to user plugins; fall back to all when there are none so the
// overlay is never empty when bundled plugins exist.
const visibleRows = scope === 'user' ? rows.filter(r => r.source !== 'bundled') : rows
const effectiveRows = scope === 'user' && !visibleRows.length && rows.length ? rows : visibleRows
const effectiveScope: Scope = effectiveRows === visibleRows ? scope : 'all'
const clampedIdx = Math.min(idx, Math.max(0, effectiveRows.length - 1))
useOverlayKeys({ disabled: busy, onClose })
const toggle = (row: PluginRow) => {
if (busy || !row) {
return
}
const enable = row.status !== 'enabled'
setBusy(true)
setErr('')
gw.request<PluginsToggleResponse>('plugins.manage', { action: 'toggle', enable, name: row.name })
.then(r => {
if (r?.plugin) {
setRows(prev => prev.map(p => (p.name === r.plugin!.name ? r.plugin! : p)))
} else {
load()
}
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setBusy(false))
}
useInput((ch, key) => {
if (busy) {
return
}
const count = effectiveRows.length
if (key.upArrow && clampedIdx > 0) {
setIdx(clampedIdx - 1)
return
}
if (key.downArrow && clampedIdx < count - 1) {
setIdx(clampedIdx + 1)
return
}
// Tab toggles user-only vs all (bundled) scope.
if (key.tab) {
setScope(s => (s === 'user' ? 'all' : 'user'))
setIdx(0)
return
}
if (key.return || ch === ' ') {
const row = effectiveRows[clampedIdx]
if (row) {
toggle(row)
}
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const next = windowOffset(count, clampedIdx, VISIBLE) + n - 1
const row = effectiveRows[next]
if (row) {
setIdx(next)
toggle(row)
}
}
})
if (loading) {
return <Text color={t.color.muted}>loading plugins</Text>
}
if (err && !rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
if (!rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>no plugins installed</Text>
<Text color={t.color.muted}>install: hermes plugins install owner/repo</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
const labels = effectiveRows.map(r => {
const status = r.status ?? 'not enabled'
const glyph = GLYPH[status] ?? '○'
const ver = r.version ? ` v${r.version}` : ''
const src = effectiveScope === 'all' && r.source === 'bundled' ? ' [bundled]' : ''
const state = status === 'enabled' ? '' : ` (${status})`
return `${glyph} ${r.name}${ver}${src}${state}`
})
const { items, offset } = windowItems(labels, clampedIdx, VISIBLE)
const scopeLabel =
effectiveScope === 'user'
? `${userCount} user plugin(s)${bundledCount ? ` · +${bundledCount} bundled (Tab)` : ''}`
: `all ${rows.length} plugins`
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>{scopeLabel}</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.map((row, i) => {
const lineIdx = offset + i
const active = clampedIdx === lineIdx
return (
<Text
bold={active}
color={active ? t.color.accent : t.color.muted}
inverse={active}
key={effectiveRows[lineIdx]?.name ?? row}
wrap="truncate-end"
>
{active ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{offset + VISIBLE < labels.length && (
<Text color={t.color.muted}> {labels.length - offset - VISIBLE} more</Text>
)}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{busy ? <Text color={t.color.accent}>updating</Text> : null}
<OverlayHint t={t}>/ select · Enter/Space toggle · Tab user/all · 1-9,0 quick · Esc/q close</OverlayHint>
</Box>
)
}
interface PluginsHubProps {
gw: GatewayClient
onClose: () => void
t: Theme
}
+14 -23
View File
@@ -7,14 +7,10 @@ import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { TextInput } from './textInput.js'
const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const
// tirith warning present → backend downgrades "always" to session scope, so drop it.
const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always')
const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
type ApprovalChoice = 'always' | 'deny' | 'once' | 'session'
type ApprovalKey = {
downArrow?: boolean
escape?: boolean
@@ -22,7 +18,10 @@ type ApprovalKey = {
upArrow?: boolean
}
type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move'; delta: -1 | 1 } | { kind: 'noop' }
type ApprovalAction =
| { kind: 'choose'; choice: (typeof OPTS)[number] }
| { kind: 'move'; delta: -1 | 1 }
| { kind: 'noop' }
/**
* Pure key-dispatch for the approval prompt — exported so the regression
@@ -32,34 +31,29 @@ type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move
*
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
* for approvals). Numbers 1..opts.length pick the labelled choice. Enter
* for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
* confirms the current selection. ↑/↓ moves the selection within bounds.
*/
export function approvalAction(
ch: string,
key: ApprovalKey,
sel: number,
opts: readonly ApprovalChoice[] = APPROVAL_OPTS
): ApprovalAction {
export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
if (key.escape) {
return { kind: 'choose', choice: 'deny' }
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= opts.length) {
return { kind: 'choose', choice: opts[n - 1]! }
if (n >= 1 && n <= OPTS.length) {
return { kind: 'choose', choice: OPTS[n - 1]! }
}
if (key.return) {
return { kind: 'choose', choice: opts[sel]! }
return { kind: 'choose', choice: OPTS[sel]! }
}
if (key.upArrow && sel > 0) {
return { kind: 'move', delta: -1 }
}
if (key.downArrow && sel < opts.length - 1) {
if (key.downArrow && sel < OPTS.length - 1) {
return { kind: 'move', delta: 1 }
}
@@ -68,10 +62,9 @@ export function approvalAction(
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS
useInput((ch, key) => {
const action = approvalAction(ch, key, sel, opts)
const action = approvalAction(ch, key, sel)
if (action.kind === 'choose') {
onChoice(action.choice)
@@ -106,7 +99,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
<Text />
{opts.map((o, i) => (
{OPTS.map((o, i) => (
<Text key={o}>
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
{sel === i ? '▸ ' : ' '}
@@ -115,9 +108,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
</Text>
))}
<Text color={t.color.muted}>
/ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny
</Text>
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>
</Box>
)
}
-24
View File
@@ -14,27 +14,3 @@ export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => {
return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}`
}
/**
* Compose the terminal titlebar string:
* `<marker> <session name> · <model> · <cwd>`
*
* The session name and cwd are each omitted when empty, and a long session
* name is truncated. The marker is always glued to the first present segment
* with a plain space (not a ` · ` separator). When no model is known yet the
* caller should fall back to a plain brand string instead of calling this.
*/
export const composeTabTitle = (
marker: string,
sessionName: string,
model: string,
cwd: string,
maxName = 28
): string => {
const name = sessionName.trim()
const shortName = name.length > maxName ? `${name.slice(0, maxName - 1)}` : name
const segments = [shortName, model, cwd].filter(Boolean)
return segments.length ? `${marker} ${segments.join(' · ')}` : marker
}
+1 -17
View File
@@ -23,22 +23,6 @@ if (!process.stdin.isTTY) {
// terminal tab can still have mouse/focus/paste modes enabled.
resetTerminalModes()
// Final backstop for terminal cleanup. setupGracefulExit() resets modes on
// signals/uncaught errors, and die()/dieWithCode() call process.exit() after
// Ink's unmount specifically so this handler can fire (see useMainApp.ts and
// #19194). But that handler was never actually installed — so /quit, Ctrl+C,
// Ctrl+D, and any process.exit() path left DEC mouse tracking (?1000/1002/
// 1003/1006) armed in the parent shell. The terminal then keeps emitting mouse
// reports into whatever reads stdin next — the shell or a freshly relaunched
// TUI mid-init — which surface as `102;71M5;104;62M`-style garbage in the input
// box (#28419). 'exit' fires exactly once on real termination and only runs
// synchronous code; resetTerminalModes() writes via writeSync, so it completes
// before the process is gone. Idempotent and cheap, so layering it under the
// graceful-exit cleanups is safe.
process.on('exit', () => {
resetTerminalModes()
})
// Desktop terminals benefit from a clean startup slate because the TUI usually
// runs in AlternateScreen. On Termux we keep prior output intact so users can
// review/copy earlier assistant replies after reopening the app.
@@ -53,7 +37,7 @@ const gw = new GatewayClient()
gw.start()
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n`
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`
setupGracefulExit({
cleanups: [
+10 -15
View File
@@ -4,8 +4,6 @@ import { existsSync } from 'node:fs'
import { delimiter, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { WebSocket as UndiciWebSocket } from 'undici'
import type { GatewayEvent } from './gatewayTypes.js'
import { CircularBuffer } from './lib/circularBuffer.js'
import { recordParentLifecycle } from './lib/parentLog.js'
@@ -21,9 +19,6 @@ const WS_OPEN = 1
const WS_CLOSING = 2
const WS_CLOSED = 3
const getWebSocketCtor = (): typeof WebSocket =>
typeof WebSocket === 'undefined' ? (UndiciWebSocket as unknown as typeof WebSocket) : WebSocket
const truncateLine = (line: string) =>
line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line
@@ -84,8 +79,12 @@ const asWireText = (raw: unknown): string | null => {
return raw
}
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
return _wireDecoder.decode(raw as any as ArrayBuffer)
if (raw instanceof ArrayBuffer) {
return _wireDecoder.decode(raw)
}
if (ArrayBuffer.isView(raw)) {
return _wireDecoder.decode(raw)
}
return null
@@ -267,16 +266,14 @@ export class GatewayClient extends EventEmitter {
return
}
const WebSocketCtor = getWebSocketCtor()
if (typeof WebSocketCtor === 'undefined') {
if (typeof WebSocket === 'undefined') {
this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`)
return
}
try {
const ws = new WebSocketCtor(this.sidecarUrl)
const ws = new WebSocket(this.sidecarUrl)
this.sidecarWs = ws
ws.addEventListener('close', () => {
@@ -409,9 +406,7 @@ export class GatewayClient extends EventEmitter {
const safeAttachUrl = redactUrl(attachUrl)
this.startReadyTimer('websocket', safeAttachUrl)
const WebSocketCtor = getWebSocketCtor()
if (typeof WebSocketCtor === 'undefined') {
if (typeof WebSocket === 'undefined') {
const line = `[startup] WebSocket API unavailable; cannot attach to ${safeAttachUrl}`
this.pushLog(line)
@@ -422,7 +417,7 @@ export class GatewayClient extends EventEmitter {
}
try {
const ws = new WebSocketCtor(attachUrl)
const ws = new WebSocket(attachUrl)
let settled = false
this.ws = ws
+1 -7
View File
@@ -103,8 +103,6 @@ export interface ConfigGetValueResponse {
}
export interface ConfigSetResponse {
confirm_message?: string
confirm_required?: boolean
credential_warning?: string
history_reset?: boolean
info?: SessionInfo
@@ -569,11 +567,7 @@ export type GatewayEvent =
session_id?: string
type: 'clarify.request'
}
| {
payload: { allow_permanent?: boolean; command: string; description: string }
session_id?: string
type: 'approval.request'
}
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
-162
View File
@@ -1,162 +0,0 @@
import { mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { performHeapDump } from './memory.js'
const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const
describe('performHeapDump auto opt-in gate (#21767)', () => {
let saved: Record<string, string | undefined>
let dir: string
beforeEach(() => {
saved = {}
for (const k of ENV_KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-test-'))
process.env.HERMES_HEAPDUMP_DIR = dir
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = saved[k]
}
}
rmSync(dir, { force: true, recursive: true })
})
it('writes diagnostics only for auto-high without HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.diagPath).toBeDefined()
expect(result.heapPath).toBeUndefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.diagnostics.json'))).toBe(true)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)
})
it('writes diagnostics only for auto-critical without HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('auto-critical')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.heapPath).toBeUndefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)
})
it('writes both diagnostics and snapshot for auto-high when HERMES_AUTO_HEAPDUMP=1', async () => {
process.env.HERMES_AUTO_HEAPDUMP = '1'
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBeUndefined()
expect(result.diagPath).toBeDefined()
expect(result.heapPath).toBeDefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)
})
it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => {
for (const value of ['true', 'YES', 'On']) {
process.env.HERMES_AUTO_HEAPDUMP = value
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.heapPath).toBeDefined()
}
})
it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => {
for (const value of ['0', 'off', 'nope']) {
process.env.HERMES_AUTO_HEAPDUMP = value
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.heapPath).toBeUndefined()
}
})
it('writes both for manual triggers regardless of HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('manual')
expect(result.success).toBe(true)
expect(result.suppressed).toBeUndefined()
expect(result.heapPath).toBeDefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)
})
})
describe('heapdump retention guard (#21767)', () => {
let savedDir: string | undefined
let savedMax: string | undefined
let dir: string
beforeEach(() => {
savedDir = process.env.HERMES_HEAPDUMP_DIR
savedMax = process.env.HERMES_HEAPDUMP_MAX_BYTES
delete process.env.HERMES_AUTO_HEAPDUMP
dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-prune-'))
process.env.HERMES_HEAPDUMP_DIR = dir
})
afterEach(() => {
if (savedDir === undefined) {delete process.env.HERMES_HEAPDUMP_DIR}
else {process.env.HERMES_HEAPDUMP_DIR = savedDir}
if (savedMax === undefined) {delete process.env.HERMES_HEAPDUMP_MAX_BYTES}
else {process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax}
rmSync(dir, { force: true, recursive: true })
})
it('evicts oldest files when total bytes exceed the cap, retaining the newest', async () => {
// 4 pre-existing dumps, 1KB each, with ascending mtimes (oldest first).
const blob = 'x'.repeat(1024)
const now = Date.now()
for (let i = 0; i < 4; i++) {
const p = join(dir, `old-${i}.heapsnapshot`)
writeFileSync(p, blob)
const t = (now - (4 - i) * 60_000) / 1000
utimesSync(p, t, t)
}
// Cap at 2KB → a fresh diagnostics write should trigger a prune down to ~cap.
process.env.HERMES_HEAPDUMP_MAX_BYTES = String(2 * 1024)
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
const remaining = readdirSync(dir)
const totalBytes = remaining.reduce((acc, f) => acc + statSync(join(dir, f)).size, 0)
// Contract: prune evicts oldest-first until total <= cap, but always keeps
// the single newest file even if it alone exceeds the cap. So either the
// total is under cap, or exactly one (newest) file remains.
expect(totalBytes <= 2 * 1024 || remaining.length === 1).toBe(true)
// The old 1KB dumps must have been pruned down from the original four.
expect(remaining.length).toBeLessThan(5)
// The brand-new diagnostics sidecar must survive the prune.
expect(remaining.some(f => f.endsWith('.diagnostics.json'))).toBe(true)
})
})
+1 -60
View File
@@ -1,5 +1,5 @@
import { createWriteStream } from 'node:fs'
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
@@ -51,9 +51,6 @@ export interface HeapDumpResult {
diagPath?: string
error?: string
heapPath?: string
// True when an auto trigger wrote diagnostics only and intentionally skipped
// the heavy snapshot because HERMES_AUTO_HEAPDUMP was not enabled (#21767).
suppressed?: boolean
success: boolean
}
@@ -156,26 +153,8 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
const heapPath = join(dir, `${base}.heapsnapshot`)
const diagPath = join(dir, `${base}.diagnostics.json`)
// The diagnostics JSON is KB-sized and the most useful artifact when a
// full snapshot is suppressed by the auto-heapdump opt-in gate below.
await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 })
// Auto triggers require explicit opt-in: multi-GiB snapshots written on
// every threshold cross can fill the user's disk (issue #21767).
const isAuto = trigger === 'auto-critical' || trigger === 'auto-high'
const autoEnabled = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_AUTO_HEAPDUMP ?? '').trim())
if (isAuto && !autoEnabled) {
await pruneHeapdumps(dir).catch(() => undefined)
// Not an error: the dump did its job — it wrote the lightweight
// diagnostics sidecar and intentionally skipped the heavy snapshot.
// `heapPath` is omitted so callers/notices report diagnostics-only.
return { diagPath, suppressed: true, success: true }
}
await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 }))
await pruneHeapdumps(dir).catch(() => undefined)
return { diagPath, heapPath, success: true }
} catch (e) {
@@ -183,44 +162,6 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis
}
}
// Cap total bytes of files in `dir`, deleting oldest first. Covers both
// `.heapsnapshot` and `.diagnostics.json` artifacts so orphan sidecars from
// gated auto-triggers cannot accumulate without bound. The newest file is
// always retained even if it alone exceeds the cap.
async function pruneHeapdumps(dir: string): Promise<void> {
const raw = process.env.HERMES_HEAPDUMP_MAX_BYTES?.trim()
const parsed = raw ? Number(raw) : NaN
const cap = Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 ** 3
const names = await readdir(dir)
const stats = await Promise.all(
names.map(async name => {
const path = join(dir, name)
const s = await stat(path).catch(() => null)
return s && s.isFile() ? { mtimeMs: s.mtimeMs, path, size: s.size } : null
})
)
const valid = stats.filter((s): s is { mtimeMs: number; path: string; size: number } => s !== null)
valid.sort((a, b) => b.mtimeMs - a.mtimeMs)
let total = valid.reduce((acc, s) => acc + s.size, 0)
while (total > cap && valid.length > 1) {
const oldest = valid.pop()
if (!oldest) {
break
}
await unlink(oldest.path).catch(() => undefined)
total -= oldest.size
}
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0B'
-13
View File
@@ -111,14 +111,6 @@ export function startMemoryMonitor({
let warned = false
const WARN_GROWTH_STEP = 150 * MB
// Cooldown prevents repeated auto dumps when heap oscillates around the
// threshold (issue #21767). `dumped` alone is not enough — it clears on
// every transition back to `normal`.
const cooldownRaw = process.env.HERMES_AUTO_HEAPDUMP_COOLDOWN_MS?.trim()
const cooldownParsed = cooldownRaw ? Number(cooldownRaw) : NaN
const cooldownMs = Number.isFinite(cooldownParsed) && cooldownParsed >= 0 ? cooldownParsed : 600_000
let lastAutoDumpAt = 0
const tick = async () => {
const { heapUsed, rss } = process.memoryUsage()
@@ -145,12 +137,7 @@ export function startMemoryMonitor({
return
}
if (Date.now() - lastAutoDumpAt < cooldownMs) {
return
}
inFlight.add(level)
lastAutoDumpAt = Date.now()
// Prune Ink content caches before dump/exit — half on 'high' (recoverable),
// full on 'critical' (post-dump RSS reduction, keeps user running).
+1 -1
View File
@@ -53,7 +53,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs =
return null
}
const timeout = new Promise<void>(resolve => setTimeout(resolve, timeoutMs))
const timeout = new Promise<undefined>(resolve => setTimeout(resolve, timeoutMs))
const query = querier.send<OscResponse>({
request: buildOsc52ClipboardQuery(),
-4
View File
@@ -90,8 +90,6 @@ export interface DelegationStatus {
}
export interface ApprovalReq {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
command: string
description: string
}
@@ -140,8 +138,6 @@ export type SectionVisibility = Partial<Record<SectionName, DetailsMode>>
export interface McpServerStatus {
connected: boolean
disabled?: boolean
status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed'
name: string
tools: number
transport: string