Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user