mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-13 03:52:00 +00:00
feat(tui): subagent spawn observability overlay
Adds a live + post-hoc audit surface for recursive delegate_task fan-out. None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay; this ships a view-switched dashboard that handles arbitrary depth + width. Python - delegate_tool: every subagent event now carries subagent_id, parent_id, depth, model, tool_count; subagent.complete also ships input/output/ reasoning tokens, cost, api_calls, files_read/files_written, and a tail of tool-call outputs - delegate_tool: new subagent.spawn_requested event + _active_subagents registry so the overlay can kill a branch by id and pause new spawns - tui_gateway: new RPCs delegation.status, delegation.pause, subagent.interrupt, spawn_tree.save/list/load (disk under \$HERMES_HOME/spawn-trees/<session>/<ts>.json) TUI - /agents overlay: full-width list mode (gantt strip + row picker) and Enter-to-drill full-width scrollable detail mode; inverse+amber selection, heat-coloured branch markers, wall-clock gantt with tick ruler, per-branch rollups - Detail pane: collapsible accordions (Budget, Files, Tool calls, Output, Progress, Summary); open-state persists across agents + mode switches via a shared atom - /replay [N|last|list|load <path>] for in-memory + disk history; /replay-diff <a> <b> for side-by-side tree comparison - Status-bar SpawnHud warns as depth/concurrency approaches caps; overlay auto-follows the just-finished turn onto history[1] - Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text globally; keep LIGHT untouched Tests: +29 new subagentTree unit tests; 215/215 passing.
This commit is contained in:
parent
ba7e8b0df9
commit
7785654ad5
19 changed files with 4329 additions and 426 deletions
|
|
@ -1,10 +1,14 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import { $turnState } from '../app/turnStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||
import { buildSubagentTree, treeTotals } from '../lib/subagentTree.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
|
@ -60,6 +64,58 @@ function ctxBar(pct: number | undefined, w = 10) {
|
|||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
function SpawnHud({ t }: { t: Theme }) {
|
||||
// Tight HUD that only appears when the session is actually fanning out.
|
||||
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||
const delegation = useStore($delegationState)
|
||||
const turn = useStore($turnState)
|
||||
|
||||
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
|
||||
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||
|
||||
if (!totals.descendantCount && !delegation.paused) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxDepth = delegation.maxSpawnDepth
|
||||
const maxConc = delegation.maxConcurrentChildren
|
||||
const depth = Math.max(0, totals.maxDepthFromHere)
|
||||
const active = totals.activeCount
|
||||
|
||||
// Concurrency here is "concurrent top-level spawns per parent at the
|
||||
// tightest branch" — approximated by the widest level in the tree.
|
||||
const depthRatio = maxDepth ? depth / maxDepth : 0
|
||||
const concRatio = maxConc ? active / maxConc : 0
|
||||
const ratio = Math.max(depthRatio, concRatio)
|
||||
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||
|
||||
const pieces: string[] = []
|
||||
|
||||
if (delegation.paused) {
|
||||
pieces.push('⏸ paused')
|
||||
}
|
||||
|
||||
if (totals.descendantCount > 0) {
|
||||
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
|
||||
pieces.push(`d${depthLabel}`)
|
||||
|
||||
if (active > 0) {
|
||||
const concLabel = maxConc ? `${active}/${maxConc}` : `${active}`
|
||||
pieces.push(`⚡${concLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const atCap = depthRatio >= 1 || concRatio >= 1
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{atCap ? ' │ ⚠ ' : ' │ '}
|
||||
{pieces.join(' ')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
|
|
@ -145,6 +201,7 @@ export function StatusRule({
|
|||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
<SpawnHud t={t} />
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue