Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-30 13:19:27 -05:00
157 changed files with 10059 additions and 831 deletions
+42 -2
View File
@@ -269,7 +269,14 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wsl text')
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
expect(commandIdx).toBeGreaterThan(-1)
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wsl text', 'utf8').toString('base64'))
})
it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
@@ -300,7 +307,13 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
expect(stdin.end).toHaveBeenCalledWith('wslg text')
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain('FromBase64String')
expect(script).toContain(Buffer.from('wslg text', 'utf8').toString('base64'))
})
it('uses PowerShell on Windows', async () => {
@@ -325,5 +338,32 @@ describe('writeClipboardText', () => {
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
expect.anything()
)
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
expect(stdin.end).not.toHaveBeenCalled()
})
it('preserves CJK text via base64 encoding in PowerShell on WSL', async () => {
const stdin = { end: vi.fn() }
const child = {
once: vi.fn((event: string, cb: (code?: number) => void) => {
if (event === 'close') {
cb(0)
}
return child
}),
stdin
}
const start = vi.fn().mockReturnValue(child)
const cjkText = '你好世界,测试中文 🎉'
await expect(writeClipboardText(cjkText, 'linux', start as any, { WSL_INTEROP: '/tmp/socket' })).resolves.toBe(true)
const calledArgs = start.mock.calls[0][1] as string[]
const commandIdx = calledArgs.indexOf('-Command')
const script = calledArgs[commandIdx + 1]
expect(script).toContain(Buffer.from(cjkText, 'utf8').toString('base64'))
expect(script).toContain('UTF8.GetString')
})
})
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
@@ -897,6 +897,117 @@ describe('createGatewayEventHandler', () => {
expect(getTurnState().subagents.find(s => s.id === 'sa-weird')?.status).toBe('completed')
})
it('nudges toward /agents on the first spawn_requested of a turn', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.spawn_requested'
} as any)
const hints = getTurnState().activity.filter(a => a.text.includes('/agents'))
expect(hints).toHaveLength(1)
expect(hints[0]).toMatchObject({ tone: 'info' })
})
it('nudges toward /agents on subagent.start (spawn_requested dropped in CLI path)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// In the real CLI→gateway path the delegate callback drops
// spawn_requested, so `start` is the first event the TUI sees.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('nudges at most once per turn and resets on the next message.start', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Multiple spawns in one turn → a single hint.
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
// New turn clears activity AND the once-per-turn guard → nudges again.
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: { goal: 'child c', subagent_id: 'sa-c', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when the /agents overlay is already open', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// User already has the dashboard open → nothing to advertise.
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('nudges if the /agents overlay is closed mid-turn while delegation continues', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
// Overlay open on the first delegation event → suppressed, but the
// turn's nudge credit must NOT be burned (the user is watching).
patchOverlayState({ agents: true })
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
// User closes the dashboard mid-turn → the next delegation event nudges.
patchOverlayState({ agents: false })
onEvent({
payload: { goal: 'child b', subagent_id: 'sa-b', task_index: 1 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(1)
})
it('does not nudge when display.tui_agents_nudge is false', async () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
// config.get → full returns the disable flag.
ctx.gateway.rpc = vi.fn(async (method: string) =>
method === 'config.get' ? { config: { display: { tui_agents_nudge: false } } } : null
)
const onEvent = createGatewayEventHandler(ctx)
// 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()
onEvent({
payload: { goal: 'child a', subagent_id: 'sa-a', task_index: 0 },
type: 'subagent.start'
} as any)
expect(getTurnState().activity.filter(a => a.text.includes('/agents'))).toHaveLength(0)
})
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for
+85 -3
View File
@@ -17,7 +17,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
@@ -123,6 +123,78 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// render a /warning close to the configured cap without spamming the RPC.
let lastDelegationFetchAt = 0
// ── Shared full-config read ──────────────────────────────────────────
//
// Several concerns need `display.*` flags at startup (the /agents nudge
// gate below, the auto-resume check in the `gateway.ready` handler).
// Memoize the `config.get full` RPC so we make exactly one round-trip
// instead of one per concern. Resolves to null on RPC failure; callers
// treat null as "use defaults".
let fullConfigPromise: null | Promise<ConfigFullResponse | null> = null
const getFullConfigOnce = (): Promise<ConfigFullResponse | null> => {
fullConfigPromise ??= rpc<ConfigFullResponse>('config.get', { key: 'full' }).catch(() => null)
return fullConfigPromise
}
// ── Nudge toward /agents on delegation ───────────────────────────────
//
// When `display.tui_agents_nudge` is enabled (default true), the first
// time a turn starts delegating we drop a single transient activity hint
// ("subagents working · /agents to watch live") so the user discovers the
// spawn-tree dashboard instead of staring at a quiet transcript — without
// hijacking the screen by force-opening an overlay. Guards:
// • fires at most once per turn (`agentsNudgedThisTurn`)
// • silent if the overlay is already open (nothing to advertise)
// Reset on `message.start`. The config flag is fetched once, lazily;
// until it resolves we assume the default (on).
let agentsNudgeEnabled = true
let agentsNudgeConfigFetched = false
let agentsNudgedThisTurn = false
const ensureAgentsNudgeConfig = () => {
if (agentsNudgeConfigFetched) {
return
}
agentsNudgeConfigFetched = true
getFullConfigOnce().then(cfg => {
// Only an explicit `false` disables it; absent/unknown keeps default on.
if (cfg?.config?.display?.tui_agents_nudge === false) {
agentsNudgeEnabled = false
}
})
}
const maybeNudgeAgents = () => {
ensureAgentsNudgeConfig()
if (!agentsNudgeEnabled || agentsNudgedThisTurn) {
return
}
// Already watching → no point advertising the dashboard. Don't burn the
// turn's nudge credit here: if the user closes the overlay later in the
// same turn while delegation is still ongoing, a subsequent event should
// still be allowed to nudge. The flag is only set once we actually push.
if (getOverlayState().agents) {
return
}
agentsNudgedThisTurn = true
turnController.pushActivity('subagents working · /agents to watch live', 'info')
}
const resetAgentsNudgeTurnState = () => {
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()
@@ -244,8 +316,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// forging a brand-new one. Mirrors classic CLI's `hermes -c` /
// `hermes --tui` muscle memory and addresses the audit's "session
// unrecoverable after disconnection" gap. Default off so existing
// users aren't surprised.
rpc<ConfigFullResponse>('config.get', { key: 'full' })
// users aren't surprised. (Shares the memoized full-config read.)
getFullConfigOnce()
.then(cfg => {
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
@@ -332,6 +404,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'message.start':
resetAgentsNudgeTurnState()
turnController.startMessage()
return
@@ -618,6 +691,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// Preserve completed state if a later event races in before this one.
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
// First sign of delegation this turn → nudge toward /agents.
maybeNudgeAgents()
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
// warn as depth/concurrency approaches the configured ceiling.
if (getDelegationState().maxSpawnDepth === null) {
@@ -631,6 +707,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'subagent.start':
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
// `subagent.start` is the first delegation event the TUI reliably
// receives (the delegate callback drops `spawn_requested` in the
// CLI→gateway path), so nudge here too. Once-per-turn guarded, so
// hooking both events is safe.
maybeNudgeAgents()
return
case 'subagent.thinking': {
const text = String(ev.payload.text ?? '').trim()
+9
View File
@@ -1073,6 +1073,10 @@ export const ToolTrail = memo(function ToolTrail({
const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid'
const childRails = nextTreeRails(rails, branch)
const hasInlineSubagents = inlineDelegateKey === group.key
// Surface the /agents hint the moment a delegate group appears —
// while it's still in-flight and before any subagent has
// registered — so users can open the live monitor immediately.
const isDelegateGroup = group.label.startsWith('Delegate Task')
return (
<Box flexDirection="column" key={group.key}>
@@ -1083,6 +1087,11 @@ export const ToolTrail = memo(function ToolTrail({
<>
<Text color={t.color.accent}> </Text>
{toolLabel(group)}
{isDelegateGroup ? (
<Text color={t.color.statusFg} dim>
{' (/agents to monitor)'}
</Text>
) : null}
</>
}
rails={rails}
+6
View File
@@ -62,6 +62,12 @@ export interface ConfigDisplayConfig {
show_reasoning?: boolean
streaming?: boolean
thinking_mode?: string
/**
* Nudge the user toward the /agents spawn-tree dashboard the first time a
* turn starts delegating, via a one-time transient activity hint. Opens
* nothing — just advertises the command. Default true.
*/
tui_agents_nudge?: boolean
tui_auto_resume_recent?: boolean
tui_compact?: boolean
/** Legacy alias for display.mouse_tracking. */
+33 -17
View File
@@ -91,33 +91,42 @@ export async function readClipboardText(
return null
}
// PowerShell on Windows/WSL decodes piped stdin with the system ANSI code
// page (e.g. CP936), not UTF-8, so $input-based writes mangle CJK/emoji. We
// instead base64-encode the UTF-8 bytes and pass them as a -Command argument,
// decoding with UTF8.GetString — this removes the stdin-encoding variable
// entirely (also immune to BOM injection on redirect). PowerShell entries set
// stdin=false; every other backend reads UTF-8 stdin natively.
type WriteCmd = { args: readonly string[]; cmd: string; stdin: boolean }
function _powershellWriteScript(b64: string): string {
return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`
}
function writeClipboardCommands(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): Array<{ args: readonly string[]; cmd: string }> {
): WriteCmd[] {
if (platform === 'darwin') {
return [{ cmd: 'pbcopy', args: [] }]
return [{ cmd: 'pbcopy', args: [], stdin: true }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive'], stdin: false }]
}
const attempts: Array<{ args: readonly string[]; cmd: string }> = []
const attempts: WriteCmd[] = []
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({
cmd: 'powershell.exe',
args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']
})
attempts.push({ cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive'], stdin: false })
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'], stdin: true })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'], stdin: true })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'], stdin: true })
return attempts
}
@@ -144,14 +153,21 @@ export async function writeClipboardText(
): Promise<boolean> {
const candidates = writeClipboardCommands(platform, env)
for (const { cmd, args } of candidates) {
for (const cmdEntry of candidates) {
try {
const ok = await new Promise<boolean>(resolve => {
const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', code => resolve(code === 0))
child.stdin?.end(text)
if (cmdEntry.stdin) {
const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
child.stdin?.end(text)
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const script = _powershellWriteScript(b64)
const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
}
})
if (ok) {