mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +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,11 +1,17 @@
|
|||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||
import type {
|
||||
CommandsCatalogResponse,
|
||||
DelegationStatusResponse,
|
||||
GatewayEvent,
|
||||
GatewaySkin
|
||||
} from '../gatewayTypes.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||
import { fromSkin } from '../theme.js'
|
||||
import type { Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { patchOverlayState } from './overlayStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
|
|
@ -53,6 +59,54 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||
|
||||
// Inject the disk-save callback into turnController so recordMessageComplete
|
||||
// can fire-and-forget a persist without having to plumb a gateway ref around.
|
||||
turnController.persistSpawnTree = async (subagents, sessionId) => {
|
||||
try {
|
||||
const startedAt = subagents.reduce<number>((min, s) => {
|
||||
if (!s.startedAt) {
|
||||
return min
|
||||
}
|
||||
|
||||
return min === 0 ? s.startedAt : Math.min(min, s.startedAt)
|
||||
}, 0)
|
||||
|
||||
const top = subagents.filter(s => !s.parentId).slice(0, 2)
|
||||
|
||||
const label = top.length
|
||||
? top.map(s => s.goal).filter(Boolean).slice(0, 2).join(' · ')
|
||||
: `${subagents.length} subagents`
|
||||
|
||||
await rpc('spawn_tree.save', {
|
||||
finished_at: Date.now() / 1000,
|
||||
label: label.slice(0, 120),
|
||||
session_id: sessionId ?? 'default',
|
||||
started_at: startedAt ? startedAt / 1000 : null,
|
||||
subagents
|
||||
})
|
||||
} catch {
|
||||
// Persistence is best-effort; in-memory history is the authoritative
|
||||
// same-session source. A write failure doesn't block the turn.
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh delegation caps at most every 5s so the status bar HUD can
|
||||
// render a /warning close to the configured cap without spamming the RPC.
|
||||
let lastDelegationFetchAt = 0
|
||||
|
||||
const refreshDelegationStatus = (force = false) => {
|
||||
const now = Date.now()
|
||||
|
||||
if (!force && now - lastDelegationFetchAt < 5000) {
|
||||
return
|
||||
}
|
||||
|
||||
lastDelegationFetchAt = now
|
||||
rpc<DelegationStatusResponse>('delegation.status', {})
|
||||
.then(r => applyDelegationStatus(r))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const setStatus = (status: string) => {
|
||||
pendingThinkingStatus = ''
|
||||
|
||||
|
|
@ -329,8 +383,27 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
return
|
||||
|
||||
case 'subagent.spawn_requested':
|
||||
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
|
||||
// Preserve completed state if a later event races in before this one.
|
||||
turnController.upsertSubagent(ev.payload, c =>
|
||||
c.status === 'completed' ? {} : { status: 'queued' }
|
||||
)
|
||||
|
||||
// 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) {
|
||||
refreshDelegationStatus(true)
|
||||
} else {
|
||||
refreshDelegationStatus()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
case 'subagent.start':
|
||||
turnController.upsertSubagent(ev.payload, () => ({ status: 'running' }))
|
||||
turnController.upsertSubagent(ev.payload, c =>
|
||||
c.status === 'completed' ? {} : { status: 'running' }
|
||||
)
|
||||
|
||||
return
|
||||
case 'subagent.thinking': {
|
||||
|
|
|
|||
77
ui-tui/src/app/delegationStore.ts
Normal file
77
ui-tui/src/app/delegationStore.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { DelegationStatusResponse } from '../gatewayTypes.js'
|
||||
|
||||
export interface DelegationState {
|
||||
// Last known caps from `delegation.status` RPC. null until fetched.
|
||||
maxConcurrentChildren: null | number
|
||||
maxSpawnDepth: null | number
|
||||
// True when spawning is globally paused (see tools/delegate_tool.py).
|
||||
paused: boolean
|
||||
// Monotonic clock of the last successful status fetch.
|
||||
updatedAt: null | number
|
||||
}
|
||||
|
||||
const buildState = (): DelegationState => ({
|
||||
maxConcurrentChildren: null,
|
||||
maxSpawnDepth: null,
|
||||
paused: false,
|
||||
updatedAt: null
|
||||
})
|
||||
|
||||
export const $delegationState = atom<DelegationState>(buildState())
|
||||
|
||||
export const getDelegationState = () => $delegationState.get()
|
||||
|
||||
export const patchDelegationState = (next: Partial<DelegationState>) =>
|
||||
$delegationState.set({ ...$delegationState.get(), ...next })
|
||||
|
||||
export const resetDelegationState = () => $delegationState.set(buildState())
|
||||
|
||||
// ── Overlay accordion open-state ──────────────────────────────────────
|
||||
//
|
||||
// Lifted out of OverlaySection's local useState so collapse choices
|
||||
// survive:
|
||||
// - navigating to a different subagent (Detail remounts)
|
||||
// - switching list ↔ detail mode (Detail unmounts in list mode)
|
||||
// - walking history (←/→)
|
||||
// Keyed by section title; missing entries fall back to the section's
|
||||
// `defaultOpen` prop.
|
||||
|
||||
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
|
||||
|
||||
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
|
||||
const state = $overlaySectionsOpen.get()
|
||||
const current = title in state ? state[title]! : defaultOpen
|
||||
|
||||
$overlaySectionsOpen.set({ ...state, [title]: !current })
|
||||
}
|
||||
|
||||
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
|
||||
const state = $overlaySectionsOpen.get()
|
||||
|
||||
return title in state ? state[title]! : defaultOpen
|
||||
}
|
||||
|
||||
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
|
||||
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
|
||||
|
||||
if (typeof r.max_spawn_depth === 'number') {
|
||||
patch.maxSpawnDepth = r.max_spawn_depth
|
||||
}
|
||||
|
||||
if (typeof r.max_concurrent_children === 'number') {
|
||||
patch.maxConcurrentChildren = r.max_concurrent_children
|
||||
}
|
||||
|
||||
if (typeof r.paused === 'boolean') {
|
||||
patch.paused = r.paused
|
||||
}
|
||||
|
||||
patchDelegationState(patch)
|
||||
}
|
||||
|
|
@ -53,6 +53,8 @@ export interface GatewayProviderProps {
|
|||
}
|
||||
|
||||
export interface OverlayState {
|
||||
agents: boolean
|
||||
agentsInitialHistoryIndex: number
|
||||
approval: ApprovalReq | null
|
||||
clarify: ClarifyReq | null
|
||||
confirm: ConfirmReq | null
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { atom, computed } from 'nanostores'
|
|||
import type { OverlayState } from './interfaces.js'
|
||||
|
||||
const buildOverlayState = (): OverlayState => ({
|
||||
agents: false,
|
||||
agentsInitialHistoryIndex: 0,
|
||||
approval: null,
|
||||
clarify: null,
|
||||
confirm: null,
|
||||
|
|
@ -18,8 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
|||
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||
)
|
||||
|
||||
export const getOverlayState = () => $overlayState.get()
|
||||
|
|
@ -27,4 +29,23 @@ export const getOverlayState = () => $overlayState.get()
|
|||
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
||||
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
||||
|
||||
/** Full reset — used by session/turn teardown and tests. */
|
||||
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
||||
|
||||
/**
|
||||
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
|
||||
* / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model
|
||||
* picker, skills hub, session picker. Those are opened deliberately and
|
||||
* shouldn't vanish when a turn ends. Called from turnController.idle() on
|
||||
* every turn completion / interrupt; the old "reset everything" behaviour
|
||||
* silently closed /agents the moment delegation finished.
|
||||
*/
|
||||
export const resetFlowOverlays = () =>
|
||||
$overlayState.set({
|
||||
...buildOverlayState(),
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
picker: $overlayState.get().picker,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
||||
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 {
|
||||
|
|
@ -42,6 +55,163 @@ interface SkillsBrowseResponse {
|
|||
}
|
||||
|
||||
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',
|
||||
|
|
|
|||
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
||||
export interface SpawnSnapshot {
|
||||
finishedAt: number
|
||||
fromDisk?: boolean
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
sessionId: null | string
|
||||
startedAt: number
|
||||
subagents: SubagentProgress[]
|
||||
}
|
||||
|
||||
export interface SpawnDiffPair {
|
||||
baseline: SpawnSnapshot
|
||||
candidate: SpawnSnapshot
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 10
|
||||
|
||||
export const $spawnHistory = atom<SpawnSnapshot[]>([])
|
||||
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
||||
|
||||
export const getSpawnHistory = () => $spawnHistory.get()
|
||||
export const getSpawnDiff = () => $spawnDiff.get()
|
||||
|
||||
export const clearSpawnHistory = () => $spawnHistory.set([])
|
||||
export const clearDiffPair = () => $spawnDiff.set(null)
|
||||
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
|
||||
|
||||
/**
|
||||
* Commit a finished turn's spawn tree to history. Keeps the last 10
|
||||
* non-empty snapshots — empty turns (no subagents) are dropped.
|
||||
*
|
||||
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
|
||||
* it misbehaved, let me look at what happened" — same-session debugging.
|
||||
* Disk persistence across process restarts is a natural extension but
|
||||
* adds RPC surface for a less-common path.
|
||||
*/
|
||||
export const pushSnapshot = (
|
||||
subagents: readonly SubagentProgress[],
|
||||
meta: { sessionId?: null | string; startedAt?: null | number }
|
||||
) => {
|
||||
if (!subagents.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
|
||||
|
||||
const snap: SpawnSnapshot = {
|
||||
finishedAt: now,
|
||||
id: `snap-${now.toString(36)}`,
|
||||
label: summarizeLabel(subagents),
|
||||
sessionId: meta.sessionId ?? null,
|
||||
startedAt: Number.isFinite(started) ? started : now,
|
||||
subagents: subagents.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||
$spawnHistory.set(next)
|
||||
}
|
||||
|
||||
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
|
||||
const top = subagents
|
||||
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
|
||||
.slice(0, 2)
|
||||
.map(s => s.goal || 'subagent')
|
||||
.join(' · ')
|
||||
|
||||
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a disk-loaded snapshot onto the front of the history stack so the
|
||||
* overlay can pick it up at index 1 via /replay load. Normalises the
|
||||
* server payload (arbitrary list) into the same SubagentProgress shape
|
||||
* used for live data — defensive against cross-version reads.
|
||||
*/
|
||||
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
|
||||
const raw = Array.isArray(r.subagents) ? r.subagents : []
|
||||
const normalised = raw.map(normaliseSubagent)
|
||||
|
||||
if (!normalised.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const snap: SpawnSnapshot = {
|
||||
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
|
||||
fromDisk: true,
|
||||
id: `disk-${path}`,
|
||||
label: r.label || `${normalised.length} subagents`,
|
||||
path,
|
||||
sessionId: r.session_id ?? null,
|
||||
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
|
||||
subagents: normalised
|
||||
}
|
||||
|
||||
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||
$spawnHistory.set(next)
|
||||
}
|
||||
|
||||
function normaliseSubagent(raw: unknown): SubagentProgress {
|
||||
const o = raw as Record<string, unknown>
|
||||
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
|
||||
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
|
||||
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
|
||||
|
||||
return {
|
||||
apiCalls: n(o.apiCalls),
|
||||
costUsd: n(o.costUsd),
|
||||
depth: typeof o.depth === 'number' ? o.depth : 0,
|
||||
durationSeconds: n(o.durationSeconds),
|
||||
filesRead: arr<string>(o.filesRead),
|
||||
filesWritten: arr<string>(o.filesWritten),
|
||||
goal: s(o.goal) ?? 'subagent',
|
||||
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
|
||||
index: typeof o.index === 'number' ? o.index : 0,
|
||||
inputTokens: n(o.inputTokens),
|
||||
iteration: n(o.iteration),
|
||||
model: s(o.model),
|
||||
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
|
||||
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
|
||||
outputTokens: n(o.outputTokens),
|
||||
parentId: s(o.parentId) ?? null,
|
||||
reasoningTokens: n(o.reasoningTokens),
|
||||
startedAt: n(o.startedAt),
|
||||
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
|
||||
summary: s(o.summary),
|
||||
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
||||
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
|
||||
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
|
||||
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
|
||||
toolsets: arr<string>(o.toolsets)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,9 @@ import {
|
|||
} from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
import { resetOverlayState } from './overlayStore.js'
|
||||
import { patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { resetFlowOverlays } from './overlayStore.js'
|
||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const INTERRUPT_COOLDOWN_MS = 1500
|
||||
|
|
@ -41,6 +42,7 @@ class TurnController {
|
|||
lastStatusNote = ''
|
||||
pendingInlineDiffs: string[] = []
|
||||
persistedToolLabels = new Set<string>()
|
||||
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||
protocolWarned = false
|
||||
reasoningText = ''
|
||||
segmentMessages: Msg[] = []
|
||||
|
|
@ -90,7 +92,7 @@ class TurnController {
|
|||
turnTrail: []
|
||||
})
|
||||
patchUiState({ busy: false })
|
||||
resetOverlayState()
|
||||
resetFlowOverlays()
|
||||
}
|
||||
|
||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
||||
|
|
@ -189,9 +191,7 @@ class TurnController {
|
|||
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
||||
// terminal printer). That header only makes sense as stdout dressing,
|
||||
// not inside a markdown ```diff block.
|
||||
const text = diffText
|
||||
.replace(/^\s*┊[^\n]*\n?/, '')
|
||||
.trim()
|
||||
const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
||||
|
||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
||||
return
|
||||
|
|
@ -249,12 +249,15 @@ class TurnController {
|
|||
// markdown fence of its own — otherwise we render two stacked diff
|
||||
// blocks for the same edit.
|
||||
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
||||
|
||||
const remainingInlineDiffs = assistantAlreadyHasDiff
|
||||
? []
|
||||
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
||||
|
||||
const inlineDiffBlock = remainingInlineDiffs.length
|
||||
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
||||
: ''
|
||||
|
||||
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||
|
|
@ -276,6 +279,20 @@ class TurnController {
|
|||
|
||||
const wasInterrupted = this.interrupted
|
||||
|
||||
// Archive the turn's spawn tree to history BEFORE idle() drops subagents
|
||||
// from turnState. Lets /replay and the overlay's history nav pull up
|
||||
// finished fan-outs without a round-trip to disk.
|
||||
const finishedSubagents = getTurnState().subagents
|
||||
const sessionId = getUiState().sid
|
||||
|
||||
if (finishedSubagents.length > 0) {
|
||||
pushSnapshot(finishedSubagents, { sessionId, startedAt: null })
|
||||
// Fire-and-forget disk persistence so /replay survives process restarts.
|
||||
// The same snapshot lives in memory via spawnHistoryStore for immediate
|
||||
// recall — disk is the long-term archive.
|
||||
void this.persistSpawnTree?.(finishedSubagents, sessionId)
|
||||
}
|
||||
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.turnTools = []
|
||||
|
|
@ -444,32 +461,69 @@ class TurnController {
|
|||
}
|
||||
|
||||
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
|
||||
const id = `sa:${p.task_index}:${p.goal || 'subagent'}`
|
||||
// Stable id: prefer the server-issued subagent_id (survives nested
|
||||
// grandchildren + cross-tree joins). Fall back to the composite key
|
||||
// for older gateways that omit the field — those produce a flat list.
|
||||
const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}`
|
||||
|
||||
patchTurnState(state => {
|
||||
const existing = state.subagents.find(item => item.id === id)
|
||||
|
||||
const base: SubagentProgress = existing ?? {
|
||||
depth: p.depth ?? 0,
|
||||
goal: p.goal,
|
||||
id,
|
||||
index: p.task_index,
|
||||
model: p.model,
|
||||
notes: [],
|
||||
parentId: p.parent_id ?? null,
|
||||
startedAt: Date.now(),
|
||||
status: 'running',
|
||||
taskCount: p.task_count ?? 1,
|
||||
thinking: [],
|
||||
tools: []
|
||||
toolCount: p.tool_count ?? 0,
|
||||
tools: [],
|
||||
toolsets: p.toolsets
|
||||
}
|
||||
|
||||
// Map snake_case payload keys onto camelCase state. Only overwrite
|
||||
// when the event actually carries the field; `??` preserves prior
|
||||
// values across streaming events that emit partial payloads.
|
||||
const outputTail = p.output_tail
|
||||
? p.output_tail.map(e => ({
|
||||
isError: Boolean(e.is_error),
|
||||
preview: String(e.preview ?? ''),
|
||||
tool: String(e.tool ?? 'tool')
|
||||
}))
|
||||
: base.outputTail
|
||||
|
||||
const next: SubagentProgress = {
|
||||
...base,
|
||||
apiCalls: p.api_calls ?? base.apiCalls,
|
||||
costUsd: p.cost_usd ?? base.costUsd,
|
||||
depth: p.depth ?? base.depth,
|
||||
filesRead: p.files_read ?? base.filesRead,
|
||||
filesWritten: p.files_written ?? base.filesWritten,
|
||||
goal: p.goal || base.goal,
|
||||
inputTokens: p.input_tokens ?? base.inputTokens,
|
||||
iteration: p.iteration ?? base.iteration,
|
||||
model: p.model ?? base.model,
|
||||
outputTail,
|
||||
outputTokens: p.output_tokens ?? base.outputTokens,
|
||||
parentId: p.parent_id ?? base.parentId,
|
||||
reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens,
|
||||
taskCount: p.task_count ?? base.taskCount,
|
||||
toolCount: p.tool_count ?? base.toolCount,
|
||||
toolsets: p.toolsets ?? base.toolsets,
|
||||
...patch(base)
|
||||
}
|
||||
|
||||
// Stable order: by spawn (depth, parent, index) rather than insert time.
|
||||
// Without it, grandchildren can shuffle relative to siblings when
|
||||
// events arrive out of order under high concurrency.
|
||||
const subagents = existing
|
||||
? state.subagents.map(item => (item.id === id ? next : item))
|
||||
: [...state.subagents, next].sort((a, b) => a.index - b.index)
|
||||
: [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index)
|
||||
|
||||
return { ...state, subagents }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
if (overlay.picker) {
|
||||
return patchOverlayState({ picker: false })
|
||||
}
|
||||
|
||||
if (overlay.agents) {
|
||||
return patchOverlayState({ agents: false })
|
||||
}
|
||||
}
|
||||
|
||||
const cycleQueue = (dir: 1 | -1) => {
|
||||
|
|
@ -180,6 +184,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
if (isCtrl(key, ch, 'c')) {
|
||||
cancelOverlayFromCtrlC()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +295,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
if (key.upArrow && !cState.inputBuf.length) {
|
||||
const inputSel = getInputSelection()
|
||||
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
||||
|
||||
const noLineAbove =
|
||||
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue