chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-04-22 11:32:17 -05:00
parent 82197a87dc
commit eda400d8a5
5 changed files with 176 additions and 94 deletions

View file

@ -868,7 +868,12 @@ def _on_tool_progress(
if _kwargs.get("toolsets"):
payload["toolsets"] = [str(t) for t in _kwargs["toolsets"]]
# Per-branch rollups emitted on subagent.complete (features 1+2+4).
for int_key in ("input_tokens", "output_tokens", "reasoning_tokens", "api_calls"):
for int_key in (
"input_tokens",
"output_tokens",
"reasoning_tokens",
"api_calls",
):
val = _kwargs.get(int_key)
if val is not None:
try:
@ -1738,16 +1743,20 @@ def _(rid, params: dict) -> dict:
# Layout: $HERMES_HOME/spawn-trees/<session_id>/<timestamp>.json
# Each file contains { session_id, started_at, finished_at, subagents: [...] }.
def _spawn_trees_root():
from pathlib import Path as _P
from hermes_constants import get_hermes_home
root = get_hermes_home() / "spawn-trees"
root.mkdir(parents=True, exist_ok=True)
return root
def _spawn_tree_session_dir(session_id: str):
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown"
safe = (
"".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown"
)
d = _spawn_trees_root() / safe
d.mkdir(parents=True, exist_ok=True)
return d
@ -1797,6 +1806,7 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4000, "subagents list required")
from datetime import datetime
started_at = params.get("started_at")
finished_at = params.get("finished_at") or time.time()
label = str(params.get("label") or "")
@ -1816,14 +1826,17 @@ def _(rid, params: dict) -> dict:
except OSError as exc:
return _err(rid, 5000, f"spawn_tree.save failed: {exc}")
_append_spawn_tree_index(d, {
"path": str(path),
"session_id": session_id,
"started_at": payload["started_at"],
"finished_at": payload["finished_at"],
"label": label,
"count": len(subagents),
})
_append_spawn_tree_index(
d,
{
"path": str(path),
"session_id": session_id,
"started_at": payload["started_at"],
"finished_at": payload["finished_at"],
"label": label,
"count": len(subagents),
},
)
return _ok(rid, {"path": str(path), "session_id": session_id})
@ -1845,7 +1858,9 @@ def _(rid, params: dict) -> dict:
indexed = _read_spawn_tree_index(d)
if indexed:
# Skip index entries whose snapshot file was manually deleted.
entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists())
entries.extend(
e for e in indexed if (p := e.get("path")) and Path(p).exists()
)
continue
# Fallback for legacy (pre-index) sessions: full scan. O(N) reads
@ -1860,14 +1875,16 @@ def _(rid, params: dict) -> dict:
except Exception:
raw = {}
subagents = raw.get("subagents") or []
entries.append({
"path": str(p),
"session_id": raw.get("session_id") or d.name,
"finished_at": raw.get("finished_at") or stat.st_mtime,
"started_at": raw.get("started_at"),
"label": raw.get("label") or "",
"count": len(subagents) if isinstance(subagents, list) else 0,
})
entries.append(
{
"path": str(p),
"session_id": raw.get("session_id") or d.name,
"finished_at": raw.get("finished_at") or stat.st_mtime,
"started_at": raw.get("started_at"),
"label": raw.get("label") or "",
"count": len(subagents) if isinstance(subagents, list) else 0,
}
)
except OSError:
continue
@ -1878,6 +1895,7 @@ def _(rid, params: dict) -> dict:
@method("spawn_tree.load")
def _(rid, params: dict) -> dict:
from pathlib import Path
raw_path = str(params.get("path") or "").strip()
if not raw_path:
return _err(rid, 4000, "path required")

View file

@ -141,8 +141,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
const isTerminalStatus = (s: SubagentProgress['status']) =>
s === 'completed' || s === 'failed' || s === 'interrupted'
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
@ -410,10 +409,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
turnController.upsertSubagent(ev.payload, c => ({
status: keepTerminalElseRunning(c.status),
thinking: pushThinking(c.thinking, text)
}))
// Update-only: never resurrect subagents whose spawn_requested/start
// we missed or that already flushed via message.complete.
turnController.upsertSubagent(
ev.payload,
c => ({
status: keepTerminalElseRunning(c.status),
thinking: pushThinking(c.thinking, text)
}),
{ createIfMissing: false }
)
return
}
@ -424,10 +429,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
ev.payload.tool_preview ?? ev.payload.text ?? ''
)
turnController.upsertSubagent(ev.payload, c => ({
status: keepTerminalElseRunning(c.status),
tools: pushTool(c.tools, line)
}))
turnController.upsertSubagent(
ev.payload,
c => ({
status: keepTerminalElseRunning(c.status),
tools: pushTool(c.tools, line)
}),
{ createIfMissing: false }
)
return
}
@ -439,20 +448,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
turnController.upsertSubagent(ev.payload, c => ({
notes: pushNote(c.notes, text),
status: keepTerminalElseRunning(c.status)
}))
turnController.upsertSubagent(
ev.payload,
c => ({
notes: pushNote(c.notes, text),
status: keepTerminalElseRunning(c.status)
}),
{ createIfMissing: false }
)
return
}
case 'subagent.complete':
turnController.upsertSubagent(ev.payload, c => ({
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
status: ev.payload.status ?? 'completed',
summary: ev.payload.summary || ev.payload.text || c.summary
}))
turnController.upsertSubagent(
ev.payload,
c => ({
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
status: ev.payload.status ?? 'completed',
summary: ev.payload.summary || ev.payload.text || c.summary
}),
{ createIfMissing: false }
)
return

View file

@ -460,7 +460,11 @@ class TurnController {
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
}
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
upsertSubagent(
p: SubagentEventPayload,
patch: (current: SubagentProgress) => Partial<SubagentProgress>,
opts: { createIfMissing?: boolean } = { createIfMissing: true }
) {
// 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.
@ -469,6 +473,14 @@ class TurnController {
patchTurnState(state => {
const existing = state.subagents.find(item => item.id === id)
// Late events (subagent.complete/tool/progress arriving after message.complete
// has already fired idle()) would otherwise resurrect a finished
// subagent into turn.subagents and block the "finished" title on the
// /agents overlay. When `createIfMissing` is false we drop silently.
if (!existing && !opts.createIfMissing) {
return state
}
const base: SubagentProgress = existing ?? {
depth: p.depth ?? 0,
goal: p.goal,

View file

@ -104,6 +104,35 @@ const fmtDur = (seconds?: number): string => {
return s === 0 ? `${m}m` : `${m}m ${s}s`
}
/** Server duration if present; else live edge from `startedAt` (running / queued). */
const displayElapsedSeconds = (item: SubagentProgress, nowMs: number): number | null => {
if (item.durationSeconds != null) {
return item.durationSeconds
}
if (item.startedAt != null && (item.status === 'running' || item.status === 'queued')) {
return Math.max(0, (nowMs - item.startedAt) / 1000)
}
return null
}
/** Like fmtDur but allows 0s for just-started / still-running rows. */
const fmtElapsedLabel = (seconds: number): string => {
if (seconds < 0) {
return ''
}
if (seconds < 60) {
return `${Math.max(0, Math.round(seconds))}s`
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds - m * 60)
return s === 0 ? `${m}m` : `${m}m ${s}s`
}
const indentFor = (depth: number): string => ' '.repeat(Math.max(0, depth))
const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ')
const cycle = <T,>(order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]!
@ -229,8 +258,11 @@ function GanttStrip({
const totalSeconds = (globalEnd - globalStart) / 1000
// 5-col id gutter (" 12 ") so the bar doesn't press against the id.
// 10-col right reserve: pad + up to `12m 30s`-style label without
// truncate-end against a full-width bar.
const idGutter = 5
const barWidth = Math.max(10, cols - idGutter - 2)
const labelReserve = 10
const barWidth = Math.max(10, cols - idGutter - labelReserve)
const startIdx = Math.max(0, Math.min(Math.max(0, spans.length - maxRows), cursor - Math.floor(maxRows / 2)))
const shown = spans.slice(startIdx, startIdx + maxRows)
@ -242,15 +274,26 @@ function GanttStrip({
return ' '.repeat(s) + '█'.repeat(fill) + ' '.repeat(Math.max(0, barWidth - s - fill))
}
// Tick ruler + second labels. Fixed-length char array guarantees
// `.length === barWidth` (an earlier padEnd+skip loop wrapped to a
// second row which looked like garbled duplicated labels).
const ruler = Array.from({ length: barWidth }, (_, i) => (i > 0 && i % 10 === 0 ? '┼' : '─')).join('')
// Wall-clock axis: more ticks on short windows so the scale visibly
// “counts up” with `now` instead of a single 0/10s pair.
const charStep = totalSeconds < 20 && barWidth > 20 ? 5 : 10
const ruler = Array.from({ length: barWidth }, (_, i) => {
if (i > 0 && i % 10 === 0) {
return '┼'
}
if (i > 0 && i % 5 === 0) {
return '·'
}
return '─'
}).join('')
const rulerLabels = (() => {
const chars = new Array(barWidth).fill(' ')
for (let pos = 0; pos < barWidth; pos += 10) {
for (let pos = 0; pos < barWidth; pos += charStep) {
const secs = (pos / barWidth) * totalSeconds
const label = pos === 0 ? '0' : secs >= 1 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s`
@ -268,7 +311,7 @@ function GanttStrip({
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.dim}>
Timeline · {fmtDur(totalSeconds)}
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
{windowLabel}
</Text>
@ -277,21 +320,24 @@ function GanttStrip({
const { color } = statusGlyph(node.item, t)
const accent = active ? t.color.amber : t.color.dim
const durLabel = node.item.durationSeconds
? fmtDur(node.item.durationSeconds)
: node.item.status === 'running'
? 'running'
: ''
const elSec = displayElapsedSeconds(node.item, now)
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
return (
<Text key={node.item.id} wrap="truncate-end">
<Text bold={active} color={accent}>
{formatRowId(idx)}{' '}
{formatRowId(idx)}
{' '}
</Text>
<Text color={active ? t.color.amber : color}>{bar(startAt, endAt)}</Text>
{durLabel ? <Text color={accent}> {durLabel}</Text> : null}
{elLabel ? (
<Text color={accent}>
{' '}
{elLabel}
</Text>
) : null}
</Text>
)
})}
@ -301,7 +347,7 @@ function GanttStrip({
{ruler}
</Text>
{totalSeconds >= 2 ? (
{totalSeconds > 0 ? (
<Text color={t.color.dim} dim>
{' '}
{rulerLabels}
@ -504,6 +550,7 @@ function ListRow({
active,
index,
node,
now,
peak,
t,
width
@ -511,6 +558,7 @@ function ListRow({
active: boolean
index: number
node: SubagentNode
now: number
peak: number
t: Theme
width: number
@ -523,35 +571,24 @@ function ListRow({
const goal = compactPreview(node.item.goal || 'subagent', width - 24 - node.item.depth * 2)
const tools = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : ''
const kids = node.children.length ? ` ·${node.children.length}` : ''
const dur = fmtDur(node.item.durationSeconds)
const elSec = displayElapsedSeconds(node.item, now)
const elapsed = elSec != null ? fmtElapsedLabel(elSec) : ''
// Selection pattern mirrors sessionPicker: inverse + amber for contrast
// across any theme, body stays cornsilk, stats dim.
const fg = active ? t.color.amber : t.color.cornsilk
// Heat marker + glyph occupy a fixed 3-char gutter so the goal text
// aligns across hot and cool rows. One space on each side of the glyph
// gives the status dot breathing room — otherwise it reads glued to the
// heat bar and the goal text.
const prefix = heatMarker ? (
<Text color={heatMarker}> </Text>
) : (
<Text>{' '}</Text>
)
return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
{active ? '▸ ' : ' '}
<Text color={active ? fg : t.color.dim}>{formatRowId(index)} </Text>
{' '}
<Text color={active ? fg : t.color.dim}>{formatRowId(index)} </Text>
{indentFor(node.item.depth)}
{prefix}
<Text color={active ? fg : color}>{glyph}</Text>
{' '}
{goal}
{heatMarker ? <Text color={heatMarker}></Text> : null}
<Text color={active ? fg : color}>{glyph}</Text> {goal}
<Text color={active ? fg : t.color.dim}>
{tools}
{kids}
{dur ? ` · ${dur}` : ''}
{elapsed ? ` · ${elapsed}` : ''}
</Text>
</Text>
)
@ -943,20 +980,23 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
? `caps d${delegation.maxSpawnDepth}/${delegation.maxConcurrentChildren ?? '?'}`
: ''
// One-line title. An earlier version had a separate "subtitle" with the
// full snapshot label; narrow terminals wrapped it instead of truncating,
// which looked like the header was double-rendered.
// Single header line — title · metrics. An earlier "title + subtitle"
// variant wrapped on narrow terminals which looked like the header was
// rendering twice, and a one-line header makes it obvious at a glance
// whether the turn is live or finished.
const title = (() => {
if (!replayMode || !effectiveSnapshot) {
return `Spawn tree${delegation.paused ? ' · ⏸ paused' : ''}`
}
const at = new Date(effectiveSnapshot.finishedAt).toLocaleTimeString()
const position = historyIndex > 0 ? `Replay · ${historyIndex}/${history.length}` : 'Last turn'
const position = historyIndex > 0 ? `Replay ${historyIndex}/${history.length}` : 'Last turn'
return `${position} · finished ${at}`
return `${position} · finished ${at}`
})()
const metaLine = [formatSummary(totals), spark, capsLabel, mix ? `· ${mix}` : ''].filter(Boolean).join(' ')
const controlsHint = replayMode
? ' · controls locked'
: ` · x kill · X subtree · p ${delegation.paused ? 'resume' : 'pause'}`
@ -970,15 +1010,16 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={replayMode ? t.color.bronze : t.color.gold} wrap="truncate-end">
{title}
</Text>
<Text color={t.color.dim} wrap="truncate-end">
{formatSummary(totals)}
{spark ? ` ${spark}` : ''}
{capsLabel ? ` ${capsLabel}` : ''}
{mix ? ` · ${mix}` : ''}
<Text wrap="truncate-end">
<Text bold color={replayMode ? t.color.bronze : t.color.gold}>
{title}
</Text>
{metaLine ? (
<Text color={t.color.dim}>
{' '}
{metaLine}
</Text>
) : null}
</Text>
</Box>
@ -997,6 +1038,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
index={listWindowStart + i}
key={node.item.id}
node={node}
now={now}
peak={peak}
t={t}
width={cols}

View file

@ -615,14 +615,7 @@ export function TextInput({
return
}
if (
(k.ctrl && inp === 'c') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) {
return
}