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:
@@ -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'
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ const buildUiState = (): UiState => ({
|
||||
pasteCollapseLines: 5,
|
||||
pasteCollapseChars: 2000,
|
||||
sections: {},
|
||||
sessionTitle: '',
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user