hermes-agent-features/ui-tui/src/app/slash/commands/ops.ts
Brooklyn Nicholson 7785654ad5 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.
2026-04-22 10:38:17 -05:00

440 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
DelegationPauseResponse,
SlashExecResponse,
SpawnTreeListResponse,
SpawnTreeLoadResponse,
ToolsConfigureResponse
} from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js'
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
import { patchOverlayState } from '../../overlayStore.js'
import {
getSpawnHistory,
pushDiskSnapshot,
setDiffPair,
type SpawnSnapshot
} from '../../spawnHistoryStore.js'
import type { SlashCommand } from '../types.js'
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsListResponse {
skills?: Record<string, string[]>
}
interface SkillsInspectResponse {
info?: SkillInfo
}
interface SkillsSearchResponse {
results?: { description?: string; name: string }[]
}
interface SkillsInstallResponse {
installed?: boolean
name?: string
}
interface SkillsBrowseItem {
description?: string
name: string
source?: string
trust?: string
}
interface SkillsBrowseResponse {
items?: SkillsBrowseItem[]
page?: number
total?: number
total_pages?: number
}
export const opsCommands: SlashCommand[] = [
{
aliases: ['tasks'],
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
name: 'agents',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
// explicit subcommands skip the overlay and act directly so scripts and
// multi-step flows can drive it without entering interactive mode.
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
const paused = sub === 'pause'
ctx.gateway.gw
.request<DelegationPauseResponse>('delegation.pause', { paused })
.then(r => {
applyDelegationStatus({ paused: r?.paused })
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
})
.catch(ctx.guardedErr)
return
}
if (sub === 'status') {
const d = getDelegationState()
ctx.transcript.sys(
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
)
return
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
name: 'replay',
run: (arg, ctx) => {
const history = getSpawnHistory()
const raw = arg.trim()
const lower = raw.toLowerCase()
// ── Disk-backed listing ─────────────────────────────────────
if (lower === 'list' || lower === 'ls') {
ctx.gateway.rpc<SpawnTreeListResponse>('spawn_tree.list', {
limit: 30,
session_id: ctx.sid ?? 'default'
})
.then(
ctx.guarded<SpawnTreeListResponse>(r => {
const entries = r.entries ?? []
if (!entries.length) {
return ctx.transcript.sys('no archived spawn trees on disk for this session')
}
const rows: [string, string][] = entries.map(e => {
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
const label = e.label || `${e.count} subagents`
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
})
ctx.transcript.panel('Archived spawn trees', [{ rows }])
})
)
.catch(ctx.guardedErr)
return
}
// ── Disk-backed load by path ─────────────────────────────────
if (lower.startsWith('load ')) {
const path = raw.slice(5).trim()
if (!path) {
return ctx.transcript.sys('usage: /replay load <path>')
}
ctx.gateway.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
.then(
ctx.guarded<SpawnTreeLoadResponse>(r => {
if (!r.subagents?.length) {
return ctx.transcript.sys('snapshot empty or unreadable')
}
// Push onto the in-memory history so the overlay picks it up
// by index 1 just like any other snapshot.
pushDiskSnapshot(r, path)
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
})
)
.catch(ctx.guardedErr)
return
}
// ── In-memory nav (same-session) ─────────────────────────────
if (!history.length) {
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
}
let index = 1
if (raw && lower !== 'last') {
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
}
index = parsed
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
}
},
{
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
name: 'replay-diff',
run: (arg, ctx) => {
const parts = arg.trim().split(/\s+/).filter(Boolean)
if (parts.length !== 2) {
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
}
const [a, b] = parts
const history = getSpawnHistory()
const resolve = (token: string): null | SpawnSnapshot => {
const n = parseInt(token!, 10)
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
return history[n - 1] ?? null
}
return null
}
const baseline = resolve(a!)
const candidate = resolve(b!)
if (!baseline || !candidate) {
return ctx.transcript.sys(
`replay-diff: could not resolve indices · history has ${history.length} entries`
)
}
setDiffPair({ baseline, candidate })
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx) => {
const text = arg.trim()
if (!text) {
return patchOverlayState({ skillsHub: true })
}
const [sub, ...rest] = text.split(/\s+/)
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { page, panel, sys } = ctx.transcript
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
.then(
ctx.guarded<SkillsListResponse>(r => {
const cats = Object.entries(r.skills ?? {}).sort()
if (!cats.length) {
return sys('no skills available')
}
panel(
'Skills',
cats.map<PanelSection>(([title, items]) => ({ items, title }))
)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'inspect') {
if (!query) {
return sys('usage: /skills inspect <name>')
}
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
.then(
ctx.guarded<SkillsInspectResponse>(r => {
const info = r.info ?? {}
if (!info.name) {
return sys(`unknown skill: ${query}`)
}
const rows: [string, string][] = [
['Name', String(info.name)],
['Category', String(info.category ?? '')],
['Path', String(info.path ?? '')]
]
const sections: PanelSection[] = [{ rows }]
if (info.description) {
sections.push({ text: String(info.description) })
}
panel('Skill', sections)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'search') {
if (!query) {
return sys('usage: /skills search <query>')
}
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
.then(
ctx.guarded<SkillsSearchResponse>(r => {
const results = r.results ?? []
if (!results.length) {
return sys(`no results for: ${query}`)
}
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'install') {
if (!query) {
return sys('usage: /skills install <name or url>')
}
sys(`installing ${query}`)
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
.then(
ctx.guarded<SkillsInstallResponse>(r =>
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
)
)
.catch(ctx.guardedErr)
return
}
if (sub === 'browse') {
const pageNum = query ? parseInt(query, 10) : 1
if (Number.isNaN(pageNum) || pageNum < 1) {
return sys('usage: /skills browse [page] (page must be a positive number)')
}
sys('fetching community skills (scans 6 sources, may take ~15s)…')
rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNum })
.then(
ctx.guarded<SkillsBrowseResponse>(r => {
const items = r.items ?? []
if (!items.length) {
return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`)
}
const rows: [string, string][] = items.map(s => [
s.trust ? `${s.name} · ${s.trust}` : s.name,
String(s.description ?? '').slice(0, 160)
])
const footer: string[] = []
if (r.page && r.total_pages) {
footer.push(`page ${r.page} of ${r.total_pages}`)
}
if (r.total) {
footer.push(`${r.total} skills total`)
}
if (r.page && r.total_pages && r.page < r.total_pages) {
footer.push(`/skills browse ${r.page + 1} for more`)
}
panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [
{ rows },
...(footer.length ? [{ text: footer.join(' · ') }] : [])
])
})
)
.catch(ctx.guardedErr)
return
}
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',
run: (arg, ctx, cmd) => {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (subcommand !== 'disable' && subcommand !== 'enable') {
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 || '/tools: 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, 'Tools') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
return
}
if (!names.length) {
ctx.transcript.sys(`usage: /tools ${subcommand} <name> [name ...]`)
ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`)
ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return
}
ctx.gateway
.rpc<ToolsConfigureResponse>('tools.configure', { action: subcommand, names, session_id: ctx.sid })
.then(
ctx.guarded<ToolsConfigureResponse>(r => {
if (r.info) {
ctx.session.setSessionStartedAt(Date.now())
ctx.session.resetVisibleHistory(r.info)
}
if (r.changed?.length) {
ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
}
if (r.unknown?.length) {
ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`)
}
if (r.missing_servers?.length) {
ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
}
if (r.reset) {
ctx.transcript.sys('session reset. new tool configuration is active.')
}
})
)
.catch(ctx.guardedErr)
}
}
]