diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 8bac6eba5..02a52afcd 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -19,11 +19,16 @@ never the child's intermediate tool calls or reasoning. import enum import json import logging + logger = logging.getLogger(__name__) import os import threading import time -from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, as_completed +from concurrent.futures import ( + ThreadPoolExecutor, + TimeoutError as FuturesTimeoutError, + as_completed, +) from typing import Any, Dict, List, Optional from toolsets import TOOLSETS @@ -32,13 +37,15 @@ from utils import base_url_hostname # Tools that children must never have access to -DELEGATE_BLOCKED_TOOLS = frozenset([ - "delegate_task", # no recursive delegation - "clarify", # no user interaction - "memory", # no writes to shared MEMORY.md - "send_message", # no cross-platform side effects - "execute_code", # children should reason step-by-step, not write scripts -]) +DELEGATE_BLOCKED_TOOLS = frozenset( + [ + "delegate_task", # no recursive delegation + "clarify", # no user interaction + "memory", # no writes to shared MEMORY.md + "send_message", # no cross-platform side effects + "execute_code", # children should reason step-by-step, not write scripts + ] +) # Build a description fragment listing toolsets available for subagents. # Excludes toolsets where ALL tools are blocked, composite/platform toolsets @@ -51,7 +58,8 @@ DELEGATE_BLOCKED_TOOLS = frozenset([ # _build_child_agent regardless of this exclusion. _EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"}) _SUBAGENT_TOOLSETS = sorted( - name for name, defn in TOOLSETS.items() + name + for name, defn in TOOLSETS.items() if name not in _EXCLUDED_TOOLSET_NAMES and not name.startswith("hermes-") and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])) @@ -66,6 +74,178 @@ _MIN_SPAWN_DEPTH = 1 _MAX_SPAWN_DEPTH_CAP = 3 +# --------------------------------------------------------------------------- +# Runtime state: pause flag + active subagent registry +# +# Consumed by the TUI observability layer (overlay/control surface) and the +# gateway RPCs `delegation.pause`, `delegation.status`, `subagent.interrupt`. +# Kept module-level so they span every delegate_task invocation in the +# process, including nested orchestrator -> worker chains. +# --------------------------------------------------------------------------- + +_spawn_pause_lock = threading.Lock() +_spawn_paused: bool = False + +_active_subagents_lock = threading.Lock() +# subagent_id -> mutable record tracking the live child agent. Stays only +# for the lifetime of the run; _run_single_child is the owner. +_active_subagents: Dict[str, Dict[str, Any]] = {} + + +def set_spawn_paused(paused: bool) -> bool: + """Globally block/unblock new delegate_task spawns. + + Active children keep running; only NEW calls to delegate_task fail fast + with a "spawning paused" error until unblocked. Returns the new state. + """ + global _spawn_paused + with _spawn_pause_lock: + _spawn_paused = bool(paused) + return _spawn_paused + + +def is_spawn_paused() -> bool: + with _spawn_pause_lock: + return _spawn_paused + + +def _register_subagent(record: Dict[str, Any]) -> None: + sid = record.get("subagent_id") + if not sid: + return + with _active_subagents_lock: + _active_subagents[sid] = record + + +def _unregister_subagent(subagent_id: str) -> None: + with _active_subagents_lock: + _active_subagents.pop(subagent_id, None) + + +def interrupt_subagent(subagent_id: str) -> bool: + """Request that a single running subagent stop at its next iteration boundary. + + Does not hard-kill the worker thread (Python can't); sets the child's + interrupt flag which propagates to in-flight tools and recurses into + grandchildren via AIAgent.interrupt(). Returns True if a matching + subagent was found. + """ + with _active_subagents_lock: + record = _active_subagents.get(subagent_id) + if not record: + return False + agent = record.get("agent") + if agent is None: + return False + try: + agent.interrupt(f"Interrupted via TUI ({subagent_id})") + except Exception as exc: + logger.debug("interrupt_subagent(%s) failed: %s", subagent_id, exc) + return False + return True + + +def list_active_subagents() -> List[Dict[str, Any]]: + """Snapshot of the currently running subagent tree. + + Each record: {subagent_id, parent_id, depth, goal, model, started_at, + tool_count, status}. Safe to call from any thread — returns a copy. + """ + with _active_subagents_lock: + return [ + {k: v for k, v in r.items() if k != "agent"} + for r in _active_subagents.values() + ] + + +def _extract_output_tail( + result: Dict[str, Any], + *, + max_entries: int = 12, + max_chars: int = 8000, +) -> List[Dict[str, Any]]: + """Pull the last N tool-call results from a child's conversation. + + Powers the overlay's "Output" section — the cc-swarm-parity feature. + We reuse the same messages list the trajectory saver walks, taking + only the tail to keep event payloads small. Each entry is + ``{tool, preview, is_error}``. + """ + messages = result.get("messages") if isinstance(result, dict) else None + if not isinstance(messages, list): + return [] + + # Walk in reverse to build a tail; stop when we have enough. + tail: List[Dict[str, Any]] = [] + pending_call_by_id: Dict[str, str] = {} + + # First pass (forward): build tool_call_id -> tool_name map + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("role") == "assistant": + for tc in msg.get("tool_calls") or []: + tc_id = tc.get("id") + fn = tc.get("function") or {} + if tc_id: + pending_call_by_id[tc_id] = str(fn.get("name") or "tool") + + # Second pass (reverse): pick tool results, newest first + for msg in reversed(messages): + if len(tail) >= max_entries: + break + if not isinstance(msg, dict) or msg.get("role") != "tool": + continue + content = msg.get("content") or "" + if not isinstance(content, str): + content = str(content) + is_error = _looks_like_error_output(content) + tool_name = pending_call_by_id.get(msg.get("tool_call_id") or "", "tool") + # Preserve line structure so the overlay's wrapped scroll region can + # show real output rather than a whitespace-collapsed blob. We still + # cap the payload size to keep events bounded. + preview = content[:max_chars] + tail.append({"tool": tool_name, "preview": preview, "is_error": is_error}) + + tail.reverse() # restore chronological order for display + return tail + + +def _looks_like_error_output(content: str) -> bool: + """Conservative stderr/error detector for tool-result previews. + + The old heuristic flagged any preview containing the substring "error", + which painted perfectly normal terminal/json output red. We now only + mark output as an error when there is stronger evidence: + - structured JSON with an ``error`` key + - structured JSON with ``status`` of error/failed + - first line starts with a classic error marker + """ + if not content: + return False + + head = content.lstrip() + if head.startswith("{") or head.startswith("["): + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + if parsed.get("error"): + return True + status = str(parsed.get("status") or "").strip().lower() + if status in {"error", "failed", "failure", "timeout"}: + return True + except Exception: + pass + + first = content.splitlines()[0].strip().lower() if content.splitlines() else "" + return ( + first.startswith("error:") + or first.startswith("failed:") + or first.startswith("traceback ") + or first.startswith("exception:") + ) + + def _normalize_role(r: Optional[str]) -> str: """Normalise a caller-provided role to 'leaf' or 'orchestrator'. @@ -100,7 +280,9 @@ def _get_max_concurrent_children() -> int: except (TypeError, ValueError): logger.warning( "delegation.max_concurrent_children=%r is not a valid integer; " - "using default %d", val, _DEFAULT_MAX_CONCURRENT_CHILDREN, + "using default %d", + val, + _DEFAULT_MAX_CONCURRENT_CHILDREN, ) return _DEFAULT_MAX_CONCURRENT_CHILDREN env_val = os.getenv("DELEGATION_MAX_CONCURRENT_CHILDREN") @@ -126,7 +308,9 @@ def _get_child_timeout() -> float: except (TypeError, ValueError): logger.warning( "delegation.child_timeout_seconds=%r is not a valid number; " - "using default %d", val, DEFAULT_CHILD_TIMEOUT, + "using default %d", + val, + DEFAULT_CHILD_TIMEOUT, ) env_val = os.getenv("DELEGATION_CHILD_TIMEOUT_SECONDS") if env_val: @@ -158,16 +342,19 @@ def _get_max_spawn_depth() -> int: ival = int(val) except (TypeError, ValueError): logger.warning( - "delegation.max_spawn_depth=%r is not a valid integer; " - "using default %d", val, MAX_DEPTH, + "delegation.max_spawn_depth=%r is not a valid integer; " "using default %d", + val, + MAX_DEPTH, ) return MAX_DEPTH clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival)) if clamped != ival: logger.warning( - "delegation.max_spawn_depth=%d out of range [%d, %d]; " - "clamping to %d", ival, _MIN_SPAWN_DEPTH, - _MAX_SPAWN_DEPTH_CAP, clamped, + "delegation.max_spawn_depth=%d out of range [%d, %d]; " "clamping to %d", + ival, + _MIN_SPAWN_DEPTH, + _MAX_SPAWN_DEPTH_CAP, + clamped, ) return clamped @@ -192,7 +379,9 @@ def _get_orchestrator_enabled() -> bool: DEFAULT_MAX_ITERATIONS = 50 DEFAULT_CHILD_TIMEOUT = 300 # seconds before a child agent is considered stuck _HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation -_HEARTBEAT_STALE_CYCLES = 5 # mark child stale after this many heartbeats with no iteration progress +_HEARTBEAT_STALE_CYCLES = ( + 5 # mark child stale after this many heartbeats with no iteration progress +) DEFAULT_TOOLSETS = ["terminal", "file", "web"] @@ -200,6 +389,7 @@ DEFAULT_TOOLSETS = ["terminal", "file", "web"] # Delegation progress event types # --------------------------------------------------------------------------- + class DelegateEvent(str, enum.Enum): """Formal event types emitted during delegation progress. @@ -211,6 +401,7 @@ class DelegateEvent(str, enum.Enum): TASK_SPAWNED / TASK_COMPLETED / TASK_FAILED are reserved for future orchestrator lifecycle events and are not currently emitted. """ + TASK_SPAWNED = "delegate.task_spawned" TASK_PROGRESS = "delegate.task_progress" TASK_COMPLETED = "delegate.task_completed" @@ -283,8 +474,8 @@ def _build_child_system_prompt( "Your own children MUST be leaves (cannot delegate further) " "because they would be at the depth floor — you cannot pass " "role='orchestrator' to your own delegate_task calls." - if child_depth + 1 >= max_spawn_depth else - "Your own children can themselves be orchestrators or leaves, " + if child_depth + 1 >= max_spawn_depth + else "Your own children can themselves be orchestrators or leaves, " "depending on the `role` you pass to delegate_task. Default is " "'leaf'; pass role='orchestrator' explicitly when a child " "needs to further decompose its work." @@ -321,7 +512,9 @@ def _resolve_workspace_hint(parent_agent) -> Optional[str]: """ candidates = [ os.getenv("TERMINAL_CWD"), - getattr(getattr(parent_agent, "_subdirectory_hints", None), "working_dir", None), + getattr( + getattr(parent_agent, "_subdirectory_hints", None), "working_dir", None + ), getattr(parent_agent, "terminal_cwd", None), getattr(parent_agent, "cwd", None), ] @@ -340,23 +533,43 @@ def _resolve_workspace_hint(parent_agent) -> Optional[str]: def _strip_blocked_tools(toolsets: List[str]) -> List[str]: """Remove toolsets that contain only blocked tools.""" blocked_toolset_names = { - "delegation", "clarify", "memory", "code_execution", + "delegation", + "clarify", + "memory", + "code_execution", } return [t for t in toolsets if t not in blocked_toolset_names] -def _build_child_progress_callback(task_index: int, goal: str, parent_agent, task_count: int = 1) -> Optional[callable]: +def _build_child_progress_callback( + task_index: int, + goal: str, + parent_agent, + task_count: int = 1, + *, + subagent_id: Optional[str] = None, + parent_id: Optional[str] = None, + depth: Optional[int] = None, + model: Optional[str] = None, + toolsets: Optional[List[str]] = None, +) -> Optional[callable]: """Build a callback that relays child agent tool calls to the parent display. Two display paths: CLI: prints tree-view lines above the parent's delegation spinner Gateway: batches tool names and relays to parent's progress callback + The identity kwargs (``subagent_id``, ``parent_id``, ``depth``, ``model``, + ``toolsets``) are threaded into every relayed event so the TUI can + reconstruct the live spawn tree and route per-branch controls (kill, + pause) back by ``subagent_id``. All are optional for backward compat — + older callers that ignore them still produce a flat list on the TUI. + Returns None if no display mechanism is available, in which case the child agent runs with no progress callback (identical to current behavior). """ - spinner = getattr(parent_agent, '_delegate_spinner', None) - parent_cb = getattr(parent_agent, 'tool_progress_callback', None) + spinner = getattr(parent_agent, "_delegate_spinner", None) + parent_cb = getattr(parent_agent, "tool_progress_callback", None) if not spinner and not parent_cb: return None # No display → no callback → zero behavior change @@ -368,30 +581,49 @@ def _build_child_progress_callback(task_index: int, goal: str, parent_agent, tas # Gateway: batch tool names, flush periodically _BATCH_SIZE = 5 _batch: List[str] = [] + _tool_count = [0] # per-subagent running counter (list for closure mutation) - def _relay(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): + def _identity_kwargs() -> Dict[str, Any]: + kw: Dict[str, Any] = { + "task_index": task_index, + "task_count": task_count, + "goal": goal_label, + } + if subagent_id is not None: + kw["subagent_id"] = subagent_id + if parent_id is not None: + kw["parent_id"] = parent_id + if depth is not None: + kw["depth"] = depth + if model is not None: + kw["model"] = model + if toolsets is not None: + kw["toolsets"] = list(toolsets) + kw["tool_count"] = _tool_count[0] + return kw + + def _relay( + event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs + ): if not parent_cb: return + payload = _identity_kwargs() + payload.update(kwargs) # caller overrides (e.g. status, duration_seconds) try: - parent_cb( - event_type, - tool_name, - preview, - args, - task_index=task_index, - task_count=task_count, - goal=goal_label, - **kwargs, - ) + parent_cb(event_type, tool_name, preview, args, **payload) except Exception as e: logger.debug("Parent callback failed: %s", e) - def _callback(event_type, tool_name: str = None, preview: str = None, args=None, **kwargs): + def _callback( + event_type, tool_name: str = None, preview: str = None, args=None, **kwargs + ): # Lifecycle events emitted by the orchestrator itself — handled # before enum normalisation since they are not part of DelegateEvent. if event_type == "subagent.start": if spinner and goal_label: - short = (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label + short = ( + (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label + ) try: spinner.print_above(f" {prefix}├─ 🔀 {short}") except Exception as e: @@ -422,7 +654,7 @@ def _build_child_progress_callback(task_index: int, goal: str, parent_agent, tas if spinner: short = (text[:55] + "...") if len(text) > 55 else text try: - spinner.print_above(f" {prefix}├─ 💭 \"{short}\"") + spinner.print_above(f' {prefix}├─ 💭 "{short}"') except Exception as e: logger.debug("Spinner print_above failed: %s", e) _relay("subagent.thinking", preview=text) @@ -453,13 +685,25 @@ def _build_child_progress_callback(task_index: int, goal: str, parent_agent, tas return # TASK_TOOL_STARTED — display and batch for parent relay + _tool_count[0] += 1 + if subagent_id is not None: + with _active_subagents_lock: + rec = _active_subagents.get(subagent_id) + if rec is not None: + rec["tool_count"] = _tool_count[0] + rec["last_tool"] = tool_name or "" if spinner: - short = (preview[:35] + "...") if preview and len(preview) > 35 else (preview or "") + short = ( + (preview[:35] + "...") + if preview and len(preview) > 35 + else (preview or "") + ) from agent.display import get_tool_emoji + emoji = get_tool_emoji(tool_name or "") line = f" {prefix}├─ {emoji} {tool_name}" if short: - line += f" \"{short}\"" + line += f' "{short}"' try: spinner.print_above(line) except Exception as e: @@ -516,6 +760,7 @@ def _build_child_agent( model on OpenRouter while the parent runs on Nous Portal). """ from run_agent import AIAgent + import uuid as _uuid # ── Role resolution ───────────────────────────────────────────────── # Honor the caller's role only when BOTH the kill switch and the @@ -523,11 +768,20 @@ def _build_child_agent( # degrades to 'leaf' — keeps the rule predictable. Callers pass # the normalised role (_normalize_role ran in delegate_task) so # we only deal with 'leaf' or 'orchestrator' here. - child_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 + child_depth = getattr(parent_agent, "_delegate_depth", 0) + 1 max_spawn = _get_max_spawn_depth() orchestrator_ok = _get_orchestrator_enabled() and child_depth < max_spawn effective_role = role if (role == "orchestrator" and orchestrator_ok) else "leaf" + # ── Subagent identity (stable across events, 0-indexed for TUI) ───── + # subagent_id is generated here so the progress callback, the + # spawn_requested event, and the _active_subagents registry all share + # one key. parent_id is non-None when THIS parent is itself a subagent + # (nested orchestrator -> worker chain). + subagent_id = f"sa-{task_index}-{_uuid.uuid4().hex[:8]}" + parent_subagent_id = getattr(parent_agent, "_subagent_id", None) + tui_depth = max(0, child_depth - 1) # 0 = first-level child for the UI + # When no explicit toolsets given, inherit from parent's enabled toolsets # so disabled tools (e.g. web) don't leak to subagents. # Note: enabled_toolsets=None means "all tools enabled" (the default), @@ -538,8 +792,10 @@ def _build_child_agent( elif parent_agent and hasattr(parent_agent, "valid_tool_names"): # enabled_toolsets is None (all tools) — derive from loaded tool names import model_tools + parent_toolsets = { - ts for name in parent_agent.valid_tool_names + ts + for name in parent_agent.valid_tool_names if (ts := model_tools.get_toolset_for_tool(name)) is not None } else: @@ -547,7 +803,9 @@ def _build_child_agent( if toolsets: # Intersect with parent — subagent must not gain tools the parent lacks - child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets]) + child_toolsets = _strip_blocked_tools( + [t for t in toolsets if t in parent_toolsets] + ) elif parent_agent and parent_enabled is not None: child_toolsets = _strip_blocked_tools(parent_enabled) elif parent_toolsets: @@ -564,7 +822,8 @@ def _build_child_agent( workspace_hint = _resolve_workspace_hint(parent_agent) child_prompt = _build_child_system_prompt( - goal, context, + goal, + context, workspace_path=workspace_hint, role=effective_role, max_spawn_depth=max_spawn, @@ -575,8 +834,22 @@ def _build_child_agent( if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"): parent_api_key = parent_agent._client_kwargs.get("api_key") - # Build progress callback to relay tool calls to parent display - child_progress_cb = _build_child_progress_callback(task_index, goal, parent_agent, task_count) + # Resolve the child's effective model early so it can ride on every event. + effective_model_for_cb = model or getattr(parent_agent, "model", None) + + # Build progress callback to relay tool calls to parent display. + # Identity kwargs thread the subagent_id through every emitted event so the + # TUI can reconstruct the spawn tree and route per-branch controls. + child_progress_cb = _build_child_progress_callback( + task_index, + goal, + parent_agent, + task_count, + subagent_id=subagent_id, + parent_id=parent_subagent_id, + depth=tui_depth, + model=effective_model_for_cb, + ) # Each subagent gets its own iteration budget capped at max_iterations # (configurable via delegation.max_iterations, default 50). This means @@ -585,6 +858,7 @@ def _build_child_agent( child_thinking_cb = None if child_progress_cb: + def _child_thinking(text: str) -> None: if not text: return @@ -601,8 +875,14 @@ def _build_child_agent( effective_base_url = override_base_url or parent_agent.base_url effective_api_key = override_api_key or parent_api_key effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None) - effective_acp_command = override_acp_command or getattr(parent_agent, "acp_command", None) - effective_acp_args = list(override_acp_args if override_acp_args is not None else (getattr(parent_agent, "acp_args", []) or [])) + effective_acp_command = override_acp_command or getattr( + parent_agent, "acp_command", None + ) + effective_acp_args = list( + override_acp_args + if override_acp_args is not None + else (getattr(parent_agent, "acp_args", []) or []) + ) # Resolve reasoning config: delegation override > parent inherit parent_reasoning = getattr(parent_agent, "reasoning_config", None) @@ -612,6 +892,7 @@ def _build_child_agent( delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip() if delegation_effort: from hermes_constants import parse_reasoning_effort + parsed = parse_reasoning_effort(delegation_effort) if parsed is not None: child_reasoning = parsed @@ -644,8 +925,8 @@ def _build_child_agent( skip_memory=True, clarify_callback=None, thinking_callback=child_thinking_cb, - session_db=getattr(parent_agent, '_session_db', None), - parent_session_id=getattr(parent_agent, 'session_id', None), + session_db=getattr(parent_agent, "_session_db", None), + parent_session_id=getattr(parent_agent, "session_id", None), providers_allowed=parent_agent.providers_allowed, providers_ignored=parent_agent.providers_ignored, providers_order=parent_agent.providers_order, @@ -653,12 +934,17 @@ def _build_child_agent( tool_progress_callback=child_progress_cb, iteration_budget=None, # fresh budget per subagent ) - child._print_fn = getattr(parent_agent, '_print_fn', None) + child._print_fn = getattr(parent_agent, "_print_fn", None) # Set delegation depth so children can't spawn grandchildren child._delegate_depth = child_depth # Stash the post-degrade role for introspection (leaf if the # kill switch or depth bounded the caller's requested role). child._delegate_role = effective_role + # Stash subagent identity for nested-delegation event propagation and + # for _run_single_child / interrupt_subagent to look up by id. + child._subagent_id = subagent_id + child._parent_subagent_id = parent_subagent_id + child._subagent_goal = goal # Share a credential pool with the child when possible so subagents can # rotate credentials on rate limits instead of getting pinned to one key. @@ -667,16 +953,26 @@ def _build_child_agent( child._credential_pool = child_pool # Register child for interrupt propagation - if hasattr(parent_agent, '_active_children'): - lock = getattr(parent_agent, '_active_children_lock', None) + if hasattr(parent_agent, "_active_children"): + lock = getattr(parent_agent, "_active_children_lock", None) if lock: with lock: parent_agent._active_children.append(child) else: parent_agent._active_children.append(child) + # Announce the spawn immediately — the child may sit in a queue + # for seconds if max_concurrent_children is saturated, so the TUI + # wants a node in the tree before run starts. + if child_progress_cb: + try: + child_progress_cb("subagent.spawn_requested", preview=goal) + except Exception as exc: + logger.debug("spawn_requested relay failed: %s", exc) + return child + def _run_single_child( task_index: int, goal: str, @@ -691,22 +987,24 @@ def _run_single_child( child_start = time.monotonic() # Get the progress callback from the child agent - child_progress_cb = getattr(child, 'tool_progress_callback', None) + child_progress_cb = getattr(child, "tool_progress_callback", None) # Restore parent tool names using the value saved before child construction # mutated the global. This is the correct parent toolset, not the child's. import model_tools - _saved_tool_names = getattr(child, "_delegate_saved_tool_names", - list(model_tools._last_resolved_tool_names)) - child_pool = getattr(child, '_credential_pool', None) + _saved_tool_names = getattr( + child, "_delegate_saved_tool_names", list(model_tools._last_resolved_tool_names) + ) + + child_pool = getattr(child, "_credential_pool", None) leased_cred_id = None if child_pool is not None: leased_cred_id = child_pool.acquire_lease() if leased_cred_id is not None: try: leased_entry = child_pool.current() - if leased_entry is not None and hasattr(child, '_swap_credential'): + if leased_entry is not None and hasattr(child, "_swap_credential"): child._swap_credential(leased_entry) except Exception as exc: logger.debug("Failed to bind child to leased credential: %s", exc) @@ -723,7 +1021,7 @@ def _run_single_child( while not _heartbeat_stop.wait(_HEARTBEAT_INTERVAL): if parent_agent is None: continue - touch = getattr(parent_agent, '_touch_activity', None) + touch = getattr(parent_agent, "_touch_activity", None) if not touch: continue # Pull detail from the child's own activity tracker @@ -748,18 +1046,23 @@ def _run_single_child( logger.warning( "Subagent %d appears stale (no iteration progress " "for %d heartbeat cycles) — stopping heartbeat", - task_index, _stale_count[0], + task_index, + _stale_count[0], ) break # stop touching parent, let gateway timeout fire if child_tool: - desc = (f"delegate_task: subagent running {child_tool} " - f"(iteration {child_iter}/{child_max})") + desc = ( + f"delegate_task: subagent running {child_tool} " + f"(iteration {child_iter}/{child_max})" + ) else: child_desc = child_summary.get("last_activity_desc", "") if child_desc: - desc = (f"delegate_task: subagent {child_desc} " - f"(iteration {child_iter}/{child_max})") + desc = ( + f"delegate_task: subagent {child_desc} " + f"(iteration {child_iter}/{child_max})" + ) except Exception: pass try: @@ -770,6 +1073,34 @@ def _run_single_child( _heartbeat_thread = threading.Thread(target=_heartbeat_loop, daemon=True) _heartbeat_thread.start() + # Register the live agent in the module-level registry so the TUI can + # target it by subagent_id (kill, pause, status queries). Unregistered + # in the finally block, even when the child raises. Test doubles that + # hand us a MagicMock don't carry stable ids; skip registration then. + _raw_sid = getattr(child, "_subagent_id", None) + _subagent_id = _raw_sid if isinstance(_raw_sid, str) else None + if _subagent_id: + _raw_depth = getattr(child, "_delegate_depth", 1) + _tui_depth = max(0, _raw_depth - 1) if isinstance(_raw_depth, int) else 0 + _parent_sid = getattr(child, "_parent_subagent_id", None) + _register_subagent( + { + "subagent_id": _subagent_id, + "parent_id": _parent_sid if isinstance(_parent_sid, str) else None, + "depth": _tui_depth, + "goal": goal, + "model": ( + getattr(child, "model", None) + if isinstance(getattr(child, "model", None), str) + else None + ), + "started_at": time.time(), + "status": "running", + "tool_count": 0, + "agent": child, + } + ) + try: if child_progress_cb: try: @@ -777,19 +1108,17 @@ def _run_single_child( except Exception as e: logger.debug("Progress callback start failed: %s", e) - # File-state coordination: generate a stable child task_id so the - # file_state registry can attribute writes back to this subagent, - # and snapshot the parent's read set at launch time. After the - # child returns we compare to detect "sibling modified files the - # parent previously read" and surface it as a reminder on the - # returned summary. + # File-state coordination: reuse the stable subagent_id as the child's + # task_id so file_state writes, active-subagents registry, and TUI + # events all share one key. Falls back to a fresh uuid only if the + # pre-built id is somehow missing. import uuid as _uuid - child_task_id = f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}" + + child_task_id = _subagent_id or f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}" parent_task_id = getattr(parent_agent, "_current_task_id", None) wall_start = time.time() parent_reads_snapshot = ( - list(file_state.known_reads(parent_task_id)) - if parent_task_id else [] + list(file_state.known_reads(parent_task_id)) if parent_task_id else [] ) # Run child with a hard timeout to prevent indefinite blocking @@ -797,16 +1126,18 @@ def _run_single_child( child_timeout = _get_child_timeout() _timeout_executor = ThreadPoolExecutor(max_workers=1) _child_future = _timeout_executor.submit( - child.run_conversation, user_message=goal, task_id=child_task_id, + child.run_conversation, + user_message=goal, + task_id=child_task_id, ) try: result = _child_future.result(timeout=child_timeout) except Exception as _timeout_exc: # Signal the child to stop so its thread can exit cleanly. try: - if hasattr(child, 'interrupt'): + if hasattr(child, "interrupt"): child.interrupt() - elif hasattr(child, '_interrupt_requested'): + elif hasattr(child, "_interrupt_requested"): child._interrupt_requested = True except Exception: pass @@ -824,7 +1155,11 @@ def _run_single_child( try: child_progress_cb( "subagent.complete", - preview=f"Timed out after {duration}s" if is_timeout else str(_timeout_exc), + preview=( + f"Timed out after {duration}s" + if is_timeout + else str(_timeout_exc) + ), status="timeout" if is_timeout else "error", duration_seconds=duration, summary="", @@ -837,9 +1172,13 @@ def _run_single_child( "status": "timeout" if is_timeout else "error", "summary": None, "error": ( - f"Subagent timed out after {child_timeout}s with no response. " - "The child may be stuck on a slow API call or unresponsive network request." - ) if is_timeout else str(_timeout_exc), + ( + f"Subagent timed out after {child_timeout}s with no response. " + "The child may be stuck on a slow API call or unresponsive network request." + ) + if is_timeout + else str(_timeout_exc) + ), "exit_reason": "timeout" if is_timeout else "error", "api_calls": 0, "duration_seconds": duration, @@ -851,7 +1190,7 @@ def _run_single_child( _timeout_executor.shutdown(wait=False) # Flush any remaining batched progress to gateway - if child_progress_cb and hasattr(child_progress_cb, '_flush'): + if child_progress_cb and hasattr(child_progress_cb, "_flush"): try: child_progress_cb._flush() except Exception as e: @@ -884,7 +1223,7 @@ def _run_single_child( if not isinstance(msg, dict): continue if msg.get("role") == "assistant": - for tc in (msg.get("tool_calls") or []): + for tc in msg.get("tool_calls") or []: fn = tc.get("function", {}) entry_t = { "tool": fn.get("name", "unknown"), @@ -896,9 +1235,7 @@ def _run_single_child( trace_by_id[tc_id] = entry_t elif msg.get("role") == "tool": content = msg.get("content", "") - is_error = bool( - content and "error" in content[:80].lower() - ) + is_error = bool(content and "error" in content[:80].lower()) result_meta = { "result_bytes": len(content), "status": "error" if is_error else "ok", @@ -934,8 +1271,12 @@ def _run_single_child( "model": _model if isinstance(_model, str) else None, "exit_reason": exit_reason, "tokens": { - "input": _input_tokens if isinstance(_input_tokens, (int, float)) else 0, - "output": _output_tokens if isinstance(_output_tokens, (int, float)) else 0, + "input": ( + _input_tokens if isinstance(_input_tokens, (int, float)) else 0 + ), + "output": ( + _output_tokens if isinstance(_output_tokens, (int, float)) else 0 + ), }, "tool_trace": tool_trace, # Captured before the finally block calls child.close() so the @@ -966,7 +1307,11 @@ def _run_single_child( "\n\n[NOTE: subagent modified files the parent " "previously read — re-read before editing: " + ", ".join(mod_paths[:8]) - + (f" (+{len(mod_paths) - 8} more)" if len(mod_paths) > 8 else "") + + ( + f" (+{len(mod_paths) - 8} more)" + if len(mod_paths) > 8 + else "" + ) + "]" ) if entry.get("summary"): @@ -976,15 +1321,63 @@ def _run_single_child( except Exception: logger.debug("file_state sibling-write check failed", exc_info=True) + # Per-branch observability payload: tokens, cost, files touched, and + # a tail of tool-call results. Fed into the TUI's overlay detail + # pane + accordion rollups (features 1, 2, 4). All fields are + # optional — missing data degrades gracefully on the client. + _cost_usd = getattr(child, "session_estimated_cost_usd", None) + _reasoning_tokens = getattr(child, "session_reasoning_tokens", 0) + try: + _files_read = list(file_state.known_reads(child_task_id))[:40] + except Exception: + _files_read = [] + try: + _files_written_map = file_state.writes_since( + "", wall_start, [] + ) # all writes since wall_start + except Exception: + _files_written_map = {} + _files_written = sorted( + { + p + for tid, paths in _files_written_map.items() + if tid == child_task_id + for p in paths + } + )[:40] + + _output_tail = _extract_output_tail(result, max_entries=8, max_chars=600) + + complete_kwargs: Dict[str, Any] = { + "preview": summary[:160] if summary else entry.get("error", ""), + "status": status, + "duration_seconds": duration, + "summary": summary[:500] if summary else entry.get("error", ""), + "input_tokens": ( + int(_input_tokens) if isinstance(_input_tokens, (int, float)) else 0 + ), + "output_tokens": ( + int(_output_tokens) if isinstance(_output_tokens, (int, float)) else 0 + ), + "reasoning_tokens": ( + int(_reasoning_tokens) + if isinstance(_reasoning_tokens, (int, float)) + else 0 + ), + "api_calls": int(api_calls) if isinstance(api_calls, (int, float)) else 0, + "files_read": _files_read, + "files_written": _files_written, + "output_tail": _output_tail, + } + if _cost_usd is not None: + try: + complete_kwargs["cost_usd"] = float(_cost_usd) + except (TypeError, ValueError): + pass + if child_progress_cb: try: - child_progress_cb( - "subagent.complete", - preview=summary[:160] if summary else entry.get("error", ""), - status=status, - duration_seconds=duration, - summary=summary[:500] if summary else entry.get("error", ""), - ) + child_progress_cb("subagent.complete", **complete_kwargs) except Exception as e: logger.debug("Progress callback completion failed: %s", e) @@ -1020,6 +1413,11 @@ def _run_single_child( _heartbeat_stop.set() _heartbeat_thread.join(timeout=5) + # Drop the TUI-facing registry entry. Safe to call even if the + # child was never registered (e.g. ID missing on test doubles). + if _subagent_id: + _unregister_subagent(_subagent_id) + if child_pool is not None and leased_cred_id is not None: try: child_pool.release_lease(leased_cred_id) @@ -1037,9 +1435,9 @@ def _run_single_child( # Remove child from active tracking # Unregister child from interrupt propagation - if hasattr(parent_agent, '_active_children'): + if hasattr(parent_agent, "_active_children"): try: - lock = getattr(parent_agent, '_active_children_lock', None) + lock = getattr(parent_agent, "_active_children_lock", None) if lock: with lock: parent_agent._active_children.remove(child) @@ -1052,11 +1450,12 @@ def _run_single_child( # background processes, httpx clients) so subagent subprocesses # don't outlive the delegation. try: - if hasattr(child, 'close'): + if hasattr(child, "close"): child.close() except Exception: logger.debug("Failed to close child agent after delegation") + def delegate_task( goal: Optional[str] = None, context: Optional[str] = None, @@ -1085,22 +1484,37 @@ def delegate_task( if parent_agent is None: return tool_error("delegate_task requires a parent agent context.") + # Operator-controlled kill switch — lets the TUI freeze new fan-out + # when a runaway tree is detected, without interrupting already-running + # children. Cleared via the matching `delegation.pause` RPC. + if is_spawn_paused(): + return json.dumps( + { + "error": ( + "Delegation spawning is paused. Clear the pause via the TUI " + "(`p` in /agents) or the `delegation.pause` RPC before retrying." + ) + } + ) + # Normalise the top-level role once; per-task overrides re-normalise. top_role = _normalize_role(role) # Depth limit — configurable via delegation.max_spawn_depth, # default 2 for parity with the original MAX_DEPTH constant. - depth = getattr(parent_agent, '_delegate_depth', 0) + depth = getattr(parent_agent, "_delegate_depth", 0) max_spawn = _get_max_spawn_depth() if depth >= max_spawn: - return json.dumps({ - "error": ( - f"Delegation depth limit reached (depth={depth}, " - f"max_spawn_depth={max_spawn}). Raise " - f"delegation.max_spawn_depth in config.yaml if deeper " - f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})." - ) - }) + return json.dumps( + { + "error": ( + f"Delegation depth limit reached (depth={depth}, " + f"max_spawn_depth={max_spawn}). Raise " + f"delegation.max_spawn_depth in config.yaml if deeper " + f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})." + ) + } + ) # Load config cfg = _load_config() @@ -1130,8 +1544,9 @@ def delegate_task( ) task_list = tasks elif goal and isinstance(goal, str) and goal.strip(): - task_list = [{"goal": goal, "context": context, - "toolsets": toolsets, "role": top_role}] + task_list = [ + {"goal": goal, "context": context, "toolsets": toolsets, "role": top_role} + ] else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") @@ -1154,6 +1569,7 @@ def delegate_task( # _build_child_agent() calls AIAgent() which calls get_tool_definitions(), # which overwrites model_tools._last_resolved_tool_names with child's toolset. import model_tools as _model_tools + _parent_tool_names = list(_model_tools._last_resolved_tool_names) # Build all child agents on the main thread (thread-safe construction) @@ -1167,15 +1583,25 @@ def delegate_task( # per-task values warn and degrade to leaf uniformly. effective_role = _normalize_role(t.get("role") or top_role) child = _build_child_agent( - task_index=i, goal=t["goal"], context=t.get("context"), - toolsets=t.get("toolsets") or toolsets, model=creds["model"], - max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent, - override_provider=creds["provider"], override_base_url=creds["base_url"], + task_index=i, + goal=t["goal"], + context=t.get("context"), + toolsets=t.get("toolsets") or toolsets, + model=creds["model"], + max_iterations=effective_max_iter, + task_count=n_tasks, + parent_agent=parent_agent, + override_provider=creds["provider"], + override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], - override_acp_command=t.get("acp_command") or acp_command or creds.get("command"), - override_acp_args=task_acp_args if task_acp_args is not None else ( - acp_args if acp_args is not None else creds.get("args") + override_acp_command=t.get("acp_command") + or acp_command + or creds.get("command"), + override_acp_args=( + task_acp_args + if task_acp_args is not None + else (acp_args if acp_args is not None else creds.get("args")) ), role=effective_role, ) @@ -1194,7 +1620,7 @@ def delegate_task( else: # Batch -- run in parallel with per-task progress lines completed_count = 0 - spinner_ref = getattr(parent_agent, '_delegate_spinner', None) + spinner_ref = getattr(parent_agent, "_delegate_spinner", None) with ThreadPoolExecutor(max_workers=max_children) as executor: futures = {} @@ -1257,7 +1683,10 @@ def delegate_task( break from concurrent.futures import wait as _cf_wait, FIRST_COMPLETED - done, pending = _cf_wait(pending, timeout=0.5, return_when=FIRST_COMPLETED) + + done, pending = _cf_wait( + pending, timeout=0.5, return_when=FIRST_COMPLETED + ) for future in done: try: entry = future.result() @@ -1279,7 +1708,9 @@ def delegate_task( # Print per-task completion line above the spinner idx = entry["task_index"] - label = task_labels[idx] if idx < len(task_labels) else f"Task {idx}" + label = ( + task_labels[idx] if idx < len(task_labels) else f"Task {idx}" + ) dur = entry.get("duration_seconds", 0) status = entry.get("status", "?") icon = "✓" if status == "completed" else "✗" @@ -1296,7 +1727,9 @@ def delegate_task( # Update spinner text to show remaining count if spinner_ref and remaining > 0: try: - spinner_ref.update_text(f"🔀 {remaining} task{'s' if remaining != 1 else ''} remaining") + spinner_ref.update_text( + f"🔀 {remaining} task{'s' if remaining != 1 else ''} remaining" + ) except Exception as e: logger.debug("Spinner update_text failed: %s", e) @@ -1304,14 +1737,26 @@ def delegate_task( results.sort(key=lambda r: r["task_index"]) # Notify parent's memory provider of delegation outcomes - if parent_agent and hasattr(parent_agent, '_memory_manager') and parent_agent._memory_manager: + if ( + parent_agent + and hasattr(parent_agent, "_memory_manager") + and parent_agent._memory_manager + ): for entry in results: try: - _task_goal = task_list[entry["task_index"]]["goal"] if entry["task_index"] < len(task_list) else "" + _task_goal = ( + task_list[entry["task_index"]]["goal"] + if entry["task_index"] < len(task_list) + else "" + ) parent_agent._memory_manager.on_delegation( task=_task_goal, result=entry.get("summary", "") or "", - child_session_id=getattr(children[entry["task_index"]][2], "session_id", "") if entry["task_index"] < len(children) else "", + child_session_id=( + getattr(children[entry["task_index"]][2], "session_id", "") + if entry["task_index"] < len(children) + else "" + ), ) except Exception: pass @@ -1345,10 +1790,13 @@ def delegate_task( total_duration = round(time.monotonic() - overall_start, 2) - return json.dumps({ - "results": results, - "total_duration_seconds": total_duration, - }, ensure_ascii=False) + return json.dumps( + { + "results": results, + "total_duration_seconds": total_duration, + }, + ensure_ascii=False, + ) def _resolve_child_credential_pool(effective_provider: Optional[str], parent_agent): @@ -1371,6 +1819,7 @@ def _resolve_child_credential_pool(effective_provider: Optional[str], parent_age try: from agent.credential_pool import load_pool + pool = load_pool(effective_provider) if pool is not None and pool.has_credentials(): return pool @@ -1404,10 +1853,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: configured_api_key = str(cfg.get("api_key") or "").strip() or None if configured_base_url: - api_key = ( - configured_api_key - or os.getenv("OPENAI_API_KEY", "").strip() - ) + api_key = configured_api_key or os.getenv("OPENAI_API_KEY", "").strip() if not api_key: raise ValueError( "Delegation base_url is configured but no API key was found. " @@ -1451,6 +1897,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: # Provider is configured — resolve full credentials try: from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested=configured_provider) except Exception as exc: raise ValueError( @@ -1488,6 +1935,7 @@ def _load_config() -> dict: """ try: from cli import CLI_CONFIG + cfg = CLI_CONFIG.get("delegation", {}) if cfg: return cfg @@ -1495,6 +1943,7 @@ def _load_config() -> dict: pass try: from hermes_cli.config import load_config + full = load_config() return full.get("delegation", {}) except Exception: @@ -1575,7 +2024,10 @@ DELEGATE_TASK_SCHEMA = { "type": "object", "properties": { "goal": {"type": "string", "description": "Task goal"}, - "context": {"type": "string", "description": "Task-specific context"}, + "context": { + "type": "string", + "description": "Task-specific context", + }, "toolsets": { "type": "array", "items": {"type": "string"}, @@ -1666,7 +2118,8 @@ registry.register( acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), role=args.get("role"), - parent_agent=kw.get("parent_agent")), + parent_agent=kw.get("parent_agent"), + ), check_fn=check_delegate_requirements, emoji="🔀", ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1397e9b04..e6519afab 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16,10 +16,13 @@ from hermes_constants import get_hermes_home from hermes_cli.env_loader import load_hermes_dotenv _hermes_home = get_hermes_home() -load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env") +load_hermes_dotenv( + hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env" +) try: from hermes_cli.banner import prefetch_update_check + prefetch_update_check() except Exception: pass @@ -35,7 +38,9 @@ _stdout_lock = threading.Lock() _cfg_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None -_SLASH_WORKER_TIMEOUT_S = max(5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45)) +_SLASH_WORKER_TIMEOUT_S = max( + 5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45) +) # ── Async RPC dispatch (#12546) ────────────────────────────────────── # A handful of handlers block the dispatcher loop in entry.py for seconds @@ -79,19 +84,31 @@ class _SlashWorker: self.stderr_tail: list[str] = [] self.stdout_queue: queue.Queue[dict | None] = queue.Queue() - argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] + argv = [ + sys.executable, + "-m", + "tui_gateway.slash_worker", + "--session-key", + session_key, + ] if model: argv += ["--model", model] self.proc = subprocess.Popen( - argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(), + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=os.getcwd(), + env=os.environ.copy(), ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() def _drain_stdout(self): - for line in (self.proc.stdout or []): + for line in self.proc.stdout or []: try: self.stdout_queue.put(json.loads(line)) except json.JSONDecodeError: @@ -99,7 +116,7 @@ class _SlashWorker: self.stdout_queue.put(None) def _drain_stderr(self): - for line in (self.proc.stderr or []): + for line in self.proc.stderr or []: if text := line.rstrip("\n"): self.stderr_tail = (self.stderr_tail + [text])[-80:] @@ -126,7 +143,9 @@ class _SlashWorker: raise RuntimeError(msg.get("error", "slash worker failed")) return str(msg.get("output", "")).rstrip() - raise RuntimeError(f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}") + raise RuntimeError( + f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}" + ) def close(self): try: @@ -134,22 +153,27 @@ class _SlashWorker: self.proc.terminate() self.proc.wait(timeout=1) except Exception: - try: self.proc.kill() - except Exception: pass + try: + self.proc.kill() + except Exception: + pass -atexit.register(lambda: [ - s.get("slash_worker") and s["slash_worker"].close() - for s in _sessions.values() -]) +atexit.register( + lambda: [ + s.get("slash_worker") and s["slash_worker"].close() for s in _sessions.values() + ] +) # ── Plumbing ────────────────────────────────────────────────────────── + def _get_db(): global _db if _db is None: from hermes_state import SessionDB + _db = SessionDB() return _db @@ -176,7 +200,11 @@ def _status_update(sid: str, kind: str, text: str | None = None): body = (text if text is not None else kind).strip() if not body: return - _emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body}) + _emit( + "status.update", + sid, + {"kind": kind if text is not None else "status", "text": body}, + ) def _estimate_image_tokens(width: int, height: int) -> int: @@ -217,6 +245,7 @@ def method(name: str): def dec(fn): _methods[name] = fn return fn + return dec @@ -272,17 +301,24 @@ def _normalize_completion_path(path_part: str) -> str: expanded = os.path.expanduser(path_part) if os.name != "nt": normalized = expanded.replace("\\", "/") - if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + if ( + len(normalized) >= 3 + and normalized[1] == ":" + and normalized[2] == "/" + and normalized[0].isalpha() + ): return f"/mnt/{normalized[0].lower()}/{normalized[3:]}" return expanded # ── Config I/O ──────────────────────────────────────────────────────── + def _load_cfg() -> dict: global _cfg_cache, _cfg_mtime try: import yaml + p = _hermes_home / "config.yaml" mtime = p.stat().st_mtime if p.exists() else None with _cfg_lock: @@ -305,6 +341,7 @@ def _load_cfg() -> dict: def _save_cfg(cfg: dict): global _cfg_cache, _cfg_mtime import yaml + path = _hermes_home / "config.yaml" with open(path, "w") as f: yaml.safe_dump(cfg, f) @@ -319,6 +356,7 @@ def _save_cfg(cfg: dict): def _set_session_context(session_key: str) -> list: try: from gateway.session_context import set_session_vars + return set_session_vars(session_key=session_key) except Exception: return [] @@ -329,6 +367,7 @@ def _clear_session_context(tokens: list) -> None: return try: from gateway.session_context import clear_session_vars + clear_session_vars(tokens) except Exception: pass @@ -343,6 +382,7 @@ def _enable_gateway_prompts() -> None: # ── Blocking prompt factory ────────────────────────────────────────── + def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: rid = uuid.uuid4().hex[:8] ev = threading.Event() @@ -371,9 +411,11 @@ def _clear_pending(sid: str | None = None) -> None: # ── Agent factory ──────────────────────────────────────────────────── + def resolve_skin() -> dict: try: from hermes_cli.skin_engine import init_skin_from_config, get_active_skin + init_skin_from_config(_load_cfg()) skin = get_active_skin() return { @@ -421,7 +463,9 @@ def _load_reasoning_config() -> dict | None: def _load_service_tier() -> str | None: - raw = str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() + raw = ( + str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() + ) if not raw or raw in {"normal", "default", "standard", "off", "none"}: return None if raw in {"fast", "priority", "on"}: @@ -448,7 +492,9 @@ def _load_enabled_toolsets() -> list[str] | None: from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools - enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) + enabled = sorted( + _get_platform_tools(load_config(), "cli", include_default_mcp_servers=False) + ) return enabled or None except Exception: return None @@ -470,7 +516,10 @@ def _restart_slash_worker(session: dict): except Exception: pass try: - session["slash_worker"] = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model())) + session["slash_worker"] = _SlashWorker( + session["session_key"], + getattr(session.get("agent"), "model", _resolve_model()), + ) except Exception: session["slash_worker"] = None @@ -549,7 +598,9 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: return {"value": result.new_model, "warning": result.warning_message or ""} -def _compress_session_history(session: dict, focus_topic: str | None = None) -> tuple[int, dict]: +def _compress_session_history( + session: dict, focus_topic: str | None = None +) -> tuple[int, dict]: from agent.model_metadata import estimate_messages_tokens_rough agent = session["agent"] @@ -592,6 +643,7 @@ def _get_usage(agent) -> dict: usage["compressions"] = getattr(comp, "compression_count", 0) or 0 try: from agent.usage_pricing import CanonicalUsage, estimate_usage_cost + cost = estimate_usage_cost( usage["model"], CanonicalUsage( @@ -637,30 +689,37 @@ def _session_info(agent) -> dict: } try: from hermes_cli import __version__, __release_date__ + info["version"] = __version__ info["release_date"] = __release_date__ except Exception: pass try: from model_tools import get_toolset_for_tool + for t in getattr(agent, "tools", []) or []: name = t["function"]["name"] - info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name) + info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append( + name + ) except Exception: pass try: from hermes_cli.banner import get_available_skills + info["skills"] = get_available_skills() except Exception: pass try: from tools.mcp_tool import get_mcp_status + info["mcp_servers"] = get_mcp_status() except Exception: info["mcp_servers"] = [] try: from hermes_cli.banner import get_update_result from hermes_cli.config import recommended_update_command + info["update_behind"] = get_update_result(timeout=0.5) info["update_command"] = recommended_update_command() except Exception: @@ -671,6 +730,7 @@ def _session_info(agent) -> dict: def _tool_ctx(name: str, args: dict) -> str: try: from agent.display import build_tool_preview + return build_tool_preview(name, args, max_len=80) or "" except Exception: return "" @@ -732,7 +792,11 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): - _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) + _emit( + "tool.start", + sid, + {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}, + ) def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): @@ -753,7 +817,13 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result from agent.display import render_edit_diff_with_delta rendered: list[str] = [] - if render_edit_diff_with_delta(name, result, function_args=args, snapshot=snapshot, print_fn=rendered.append): + if render_edit_diff_with_delta( + name, + result, + function_args=args, + snapshot=snapshot, + print_fn=rendered.append, + ): payload["inline_diff"] = "\n".join(rendered) except Exception: pass @@ -783,6 +853,39 @@ def _on_tool_progress( "task_count": int(_kwargs.get("task_count") or 1), "task_index": int(_kwargs.get("task_index") or 0), } + # Identity fields for the TUI spawn tree. All optional — older + # emitters that omit them fall back to flat rendering client-side. + if _kwargs.get("subagent_id"): + payload["subagent_id"] = str(_kwargs["subagent_id"]) + if _kwargs.get("parent_id"): + payload["parent_id"] = str(_kwargs["parent_id"]) + if _kwargs.get("depth") is not None: + payload["depth"] = int(_kwargs["depth"]) + if _kwargs.get("model"): + payload["model"] = str(_kwargs["model"]) + if _kwargs.get("tool_count") is not None: + payload["tool_count"] = int(_kwargs["tool_count"]) + 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"): + val = _kwargs.get(int_key) + if val is not None: + try: + payload[int_key] = int(val) + except (TypeError, ValueError): + pass + if _kwargs.get("cost_usd") is not None: + try: + payload["cost_usd"] = float(_kwargs["cost_usd"]) + except (TypeError, ValueError): + pass + if _kwargs.get("files_read"): + payload["files_read"] = [str(p) for p in _kwargs["files_read"]] + if _kwargs.get("files_written"): + payload["files_written"] = [str(p) for p in _kwargs["files_written"]] + if _kwargs.get("output_tail"): + payload["output_tail"] = list(_kwargs["output_tail"]) # list of dicts if name: payload["tool_name"] = str(name) if preview: @@ -801,16 +904,25 @@ def _on_tool_progress( def _agent_cbs(sid: str) -> dict: return dict( - tool_start_callback=lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args), - tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete(sid, tc_id, name, args, result), + tool_start_callback=lambda tc_id, name, args: _on_tool_start( + sid, tc_id, name, args + ), + tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete( + sid, tc_id, name, args, result + ), tool_progress_callback=lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( sid, event_type, name, preview, args, **kwargs ), - tool_gen_callback=lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), + tool_gen_callback=lambda name: _tool_progress_enabled(sid) + and _emit("tool.generating", sid, {"name": name}), thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), - status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), - clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), + status_callback=lambda kind, text=None: _status_update( + sid, str(kind), None if text is None else str(text) + ), + clarify_callback=lambda q, c: _block( + "clarify.request", sid, {"question": q, "choices": c} + ), ) @@ -826,9 +938,20 @@ def _wire_callbacks(sid: str): pl["metadata"] = metadata val = _block("secret.request", sid, pl) if not val: - return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} + return { + "success": True, + "stored_as": env_var, + "validated": False, + "skipped": True, + "message": "skipped", + } from hermes_cli.config import save_env_value_secure - return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} + + return { + **save_env_value_secure(env_var, val), + "skipped": False, + "message": "ok", + } set_secret_capture_callback(secret_cb) @@ -901,7 +1024,9 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str return name, _render_personality_prompt(personalities[name]) -def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> tuple[bool, dict | None]: +def _apply_personality_to_session( + sid: str, session: dict, new_prompt: str +) -> tuple[bool, dict | None]: if not session: return False, None @@ -931,18 +1056,23 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: "acp_args": getattr(agent, "acp_args", None) or None, "model": getattr(agent, "model", None) or _resolve_model(), "max_iterations": int(cfg.get("max_turns", 25) or 25), - "enabled_toolsets": getattr(agent, "enabled_toolsets", None) or _load_enabled_toolsets(), + "enabled_toolsets": getattr(agent, "enabled_toolsets", None) + or _load_enabled_toolsets(), "quiet_mode": True, "verbose_logging": False, - "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None) or None, + "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None) + or None, "providers_allowed": getattr(agent, "providers_allowed", None), "providers_ignored": getattr(agent, "providers_ignored", None), "providers_order": getattr(agent, "providers_order", None), "provider_sort": getattr(agent, "provider_sort", None), - "provider_require_parameters": getattr(agent, "provider_require_parameters", False), + "provider_require_parameters": getattr( + agent, "provider_require_parameters", False + ), "provider_data_collection": getattr(agent, "provider_data_collection", None), "session_id": task_id, - "reasoning_config": getattr(agent, "reasoning_config", None) or _load_reasoning_config(), + "reasoning_config": getattr(agent, "reasoning_config", None) + or _load_reasoning_config(), "service_tier": getattr(agent, "service_tier", None) or _load_service_tier(), "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "platform": "tui", @@ -954,7 +1084,9 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: def _reset_session_agent(sid: str, session: dict) -> dict: tokens = _set_session_context(session["session_key"]) try: - new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"]) + new_agent = _make_agent( + sid, session["session_key"], session_id=session["session_key"] + ) finally: _clear_session_context(tokens) session["agent"] = new_agent @@ -976,6 +1108,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict: def _make_agent(sid: str, key: str, session_id: str | None = None): from run_agent import AIAgent + cfg = _load_cfg() system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" if not system_prompt: @@ -988,7 +1121,8 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): service_tier=_load_service_tier(), enabled_toolsets=_load_enabled_toolsets(), platform="tui", - session_id=session_id or key, session_db=_get_db(), + session_id=session_id or key, + session_db=_get_db(), ephemeral_system_prompt=system_prompt or None, **_agent_cbs(sid), ) @@ -1012,12 +1146,15 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "tool_started_at": {}, } try: - _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + _sessions[sid]["slash_worker"] = _SlashWorker( + key, getattr(agent, "model", _resolve_model()) + ) except Exception: # Defer hard-failure to slash.exec; chat still works without slash worker. _sessions[sid]["slash_worker"] = None try: from tools.approval import register_gateway_notify, load_permanent_allowlist + register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) load_permanent_allowlist() except Exception: @@ -1063,10 +1200,15 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: continue hint = f"[You can examine it with vision_analyze using image_url: {p}]" try: - r = _json.loads(asyncio.run(vision_analyze_tool(image_url=str(p), user_prompt=prompt))) + r = _json.loads( + asyncio.run(vision_analyze_tool(image_url=str(p), user_prompt=prompt)) + ) desc = r.get("analysis", "") if r.get("success") else None - parts.append(f"[The user attached an image:\n{desc}]\n{hint}" if desc - else f"[The user attached an image but analysis failed.]\n{hint}") + parts.append( + f"[The user attached an image:\n{desc}]\n{hint}" + if desc + else f"[The user attached an image but analysis failed.]\n{hint}" + ) except Exception: parts.append(f"[The user attached an image but analysis failed.]\n{hint}") @@ -1104,7 +1246,9 @@ def _history_to_messages(history: list[dict]) -> list[dict]: tc_info = tool_call_args.get(tc_id) if tc_id else None name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" args = (tc_info[1] if tc_info else None) or {} - messages.append({"role": "tool", "name": name, "context": _tool_ctx(name, args)}) + messages.append( + {"role": "tool", "name": name, "context": _tool_ctx(name, args)} + ) continue if not (m.get("content") or "").strip(): continue @@ -1115,6 +1259,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]: # ── Methods: session ───────────────────────────────────────────────── + @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] @@ -1178,8 +1323,14 @@ def _(rid, params: dict) -> dict: pass try: - from tools.approval import register_gateway_notify, load_permanent_allowlist - register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + from tools.approval import ( + register_gateway_notify, + load_permanent_allowlist, + ) + + register_gateway_notify( + key, lambda data: _emit("approval.request", sid, data) + ) notify_registered = True load_permanent_allowlist() except Exception: @@ -1210,6 +1361,7 @@ def _(rid, params: dict) -> dict: if notify_registered: try: from tools.approval import unregister_gateway_notify + unregister_gateway_notify(key) except Exception: pass @@ -1217,15 +1369,18 @@ def _(rid, params: dict) -> dict: threading.Thread(target=_build, daemon=True).start() - return _ok(rid, { - "session_id": sid, - "info": { - "model": _resolve_model(), - "tools": {}, - "skills": {}, - "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + return _ok( + rid, + { + "session_id": sid, + "info": { + "model": _resolve_model(), + "tools": {}, + "skills": {}, + "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + }, }, - }) + ) @method("session.list") @@ -1259,12 +1414,22 @@ def _(rid, params: dict) -> dict: for s in _get_db().list_sessions_rich(source=None, limit=fetch_limit) if (s.get("source") or "").strip().lower() in allow ][:limit] - return _ok(rid, {"sessions": [ - {"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "", - "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0, - "source": s.get("source") or ""} - for s in rows - ]}) + return _ok( + rid, + { + "sessions": [ + { + "id": s["id"], + "title": s.get("title") or "", + "preview": s.get("preview") or "", + "started_at": s.get("started_at") or 0, + "message_count": s.get("message_count") or 0, + "source": s.get("source") or "", + } + for s in rows + ] + }, + ) except Exception as e: return _err(rid, 5006, str(e)) @@ -1315,7 +1480,9 @@ def _(rid, params: dict) -> dict: return err title, key = params.get("title", ""), session["session_key"] if not title: - return _ok(rid, {"title": _get_db().get_session_title(key) or "", "session_key": key}) + return _ok( + rid, {"title": _get_db().get_session_title(key) or "", "session_key": key} + ) try: _get_db().set_session_title(key, title) return _ok(rid, {"title": title}) @@ -1352,7 +1519,9 @@ def _(rid, params: dict) -> dict: # silently drop the agent's output (version mismatch, see below). # Neither is what the user wants — make them /interrupt first. if session.get("running"): - return _err(rid, 4009, "session busy — /interrupt the current turn before /undo") + return _err( + rid, 4009, "session busy — /interrupt the current turn before /undo" + ) removed = 0 with session["history_lock"]: history = session.get("history", []) @@ -1373,14 +1542,27 @@ def _(rid, params: dict) -> dict: if err: return err if session.get("running"): - return _err(rid, 4009, "session busy — /interrupt the current turn before /compress") + return _err( + rid, 4009, "session busy — /interrupt the current turn before /compress" + ) try: with session["history_lock"]: - removed, usage = _compress_session_history(session, str(params.get("focus_topic", "") or "").strip()) + removed, usage = _compress_session_history( + session, str(params.get("focus_topic", "") or "").strip() + ) messages = list(session.get("history", [])) info = _session_info(session["agent"]) _emit("session.info", params.get("session_id", ""), info) - return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage, "info": info, "messages": messages}) + return _ok( + rid, + { + "status": "compressed", + "removed": removed, + "usage": usage, + "info": info, + "messages": messages, + }, + ) except Exception as e: return _err(rid, 5005, str(e)) @@ -1391,11 +1573,21 @@ def _(rid, params: dict) -> dict: if err: return err import time as _time - filename = os.path.abspath(f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json") + + filename = os.path.abspath( + f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" + ) try: with open(filename, "w") as f: - json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])}, - f, indent=2, ensure_ascii=False) + json.dump( + { + "model": getattr(session["agent"], "model", ""), + "messages": session.get("history", []), + }, + f, + indent=2, + ensure_ascii=False, + ) return _ok(rid, {"file": filename}) except Exception as e: return _err(rid, 5011, str(e)) @@ -1440,10 +1632,20 @@ def _(rid, params: dict) -> dict: title = branch_name else: current = db.get_session_title(old_key) or "branch" - title = db.get_next_title_in_lineage(current) if hasattr(db, "get_next_title_in_lineage") else f"{current} (branch)" - db.create_session(new_key, source="tui", model=_resolve_model(), parent_session_id=old_key) + title = ( + db.get_next_title_in_lineage(current) + if hasattr(db, "get_next_title_in_lineage") + else f"{current} (branch)" + ) + db.create_session( + new_key, source="tui", model=_resolve_model(), parent_session_id=old_key + ) for msg in history: - db.append_message(session_id=new_key, role=msg.get("role", "user"), content=msg.get("content")) + db.append_message( + session_id=new_key, + role=msg.get("role", "user"), + content=msg.get("content"), + ) db.set_session_title(new_key, title) except Exception as e: return _err(rid, 5008, f"branch failed: {e}") @@ -1454,7 +1656,9 @@ def _(rid, params: dict) -> dict: agent = _make_agent(new_sid, new_key, session_id=new_key) finally: _clear_session_context(tokens) - _init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80)) + _init_session( + new_sid, new_key, agent, list(history), cols=session.get("cols", 80) + ) except Exception as e: return _err(rid, 5000, f"agent init failed on branch: {e}") return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key}) @@ -1474,12 +1678,173 @@ def _(rid, params: dict) -> dict: _clear_pending(params.get("session_id", "")) try: from tools.approval import resolve_gateway_approval + resolve_gateway_approval(session["session_key"], "deny", resolve_all=True) except Exception: pass return _ok(rid, {"status": "interrupted"}) +# ── Delegation: subagent tree observability + controls ─────────────── +# Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). +# The registry lives in tools/delegate_tool — these handlers are thin +# translators between JSON-RPC and the Python API. + + +@method("delegation.status") +def _(rid, params: dict) -> dict: + from tools.delegate_tool import ( + is_spawn_paused, + list_active_subagents, + _get_max_concurrent_children, + _get_max_spawn_depth, + ) + + return _ok( + rid, + { + "active": list_active_subagents(), + "paused": is_spawn_paused(), + "max_spawn_depth": _get_max_spawn_depth(), + "max_concurrent_children": _get_max_concurrent_children(), + }, + ) + + +@method("delegation.pause") +def _(rid, params: dict) -> dict: + from tools.delegate_tool import set_spawn_paused + + paused = bool(params.get("paused", True)) + return _ok(rid, {"paused": set_spawn_paused(paused)}) + + +@method("subagent.interrupt") +def _(rid, params: dict) -> dict: + from tools.delegate_tool import interrupt_subagent + + subagent_id = str(params.get("subagent_id") or "").strip() + if not subagent_id: + return _err(rid, 4000, "subagent_id required") + ok = interrupt_subagent(subagent_id) + return _ok(rid, {"found": ok, "subagent_id": subagent_id}) + + +# ── Spawn-tree snapshots: TUI-written, disk-persisted ──────────────── +# The TUI is the source of truth for subagent state (it assembles payloads +# from the event stream). On turn-complete it posts the final tree here; +# /replay and /replay-diff fetch past snapshots by session_id + filename. +# +# Layout: $HERMES_HOME/spawn-trees//.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" + d = _spawn_trees_root() / safe + d.mkdir(parents=True, exist_ok=True) + return d + + +@method("spawn_tree.save") +def _(rid, params: dict) -> dict: + session_id = str(params.get("session_id") or "").strip() + subagents = params.get("subagents") or [] + if not isinstance(subagents, list) or not subagents: + 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 "") + ts = datetime.utcfromtimestamp(float(finished_at)).strftime("%Y%m%dT%H%M%S") + fname = f"{ts}.json" + d = _spawn_tree_session_dir(session_id or "default") + path = d / fname + try: + payload = { + "session_id": session_id, + "started_at": float(started_at) if started_at else None, + "finished_at": float(finished_at), + "label": label, + "subagents": subagents, + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + except OSError as exc: + return _err(rid, 5000, f"spawn_tree.save failed: {exc}") + + return _ok(rid, {"path": str(path), "session_id": session_id}) + + +@method("spawn_tree.list") +def _(rid, params: dict) -> dict: + session_id = str(params.get("session_id") or "").strip() + limit = int(params.get("limit") or 50) + cross_session = bool(params.get("cross_session")) + + roots = [] + if cross_session: + root = _spawn_trees_root() + roots = [p for p in root.iterdir() if p.is_dir()] + else: + roots = [_spawn_tree_session_dir(session_id or "default")] + + entries = [] + for d in roots: + for p in d.glob("*.json"): + try: + stat = p.stat() + # Peek at the header for label/counts without parsing the full list. + try: + raw = json.loads(p.read_text(encoding="utf-8")) + 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, + }) + except OSError: + continue + + entries.sort(key=lambda e: e.get("finished_at") or 0, reverse=True) + return _ok(rid, {"entries": entries[:limit]}) + + +@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") + + # Reject paths escaping the spawn-trees root. + root = _spawn_trees_root().resolve() + try: + resolved = Path(raw_path).resolve() + resolved.relative_to(root) + except (ValueError, OSError) as exc: + return _err(rid, 4030, f"path outside spawn-trees root: {exc}") + + try: + payload = json.loads(resolved.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return _err(rid, 5000, f"spawn_tree.load failed: {exc}") + + return _ok(rid, payload) + + @method("session.steer") def _(rid, params: dict) -> dict: """Inject a user message into the next tool result without interrupting. @@ -1516,6 +1881,7 @@ def _(rid, params: dict) -> dict: # ── Methods: prompt ────────────────────────────────────────────────── + @method("prompt.submit") def _(rid, params: dict) -> dict: sid, text = params.get("session_id", ""), params.get("text", "") @@ -1537,7 +1903,11 @@ def _(rid, params: dict) -> dict: approval_token = None session_tokens = [] try: - from tools.approval import reset_current_session_key, set_current_session_key + from tools.approval import ( + reset_current_session_key, + set_current_session_key, + ) + approval_token = set_current_session_key(session["session_key"]) session_tokens = _set_session_context(session["session_key"]) cols = session.get("cols", 80) @@ -1560,7 +1930,14 @@ def _(rid, params: dict) -> dict: context_length=ctx_len, ) if ctx.blocked: - _emit("error", sid, {"message": "\n".join(ctx.warnings) or "Context injection refused."}) + _emit( + "error", + sid, + { + "message": "\n".join(ctx.warnings) + or "Context injection refused." + }, + ) return prompt = ctx.message @@ -1573,7 +1950,8 @@ def _(rid, params: dict) -> dict: _emit("message.delta", sid, payload) result = agent.run_conversation( - prompt, conversation_history=list(history), + prompt, + conversation_history=list(history), stream_callback=_stream, ) @@ -1606,7 +1984,11 @@ def _(rid, params: dict) -> dict: "but was not saved to session history." ) raw = result.get("final_response", "") - status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" + status = ( + "interrupted" + if result.get("interrupted") + else "error" if result.get("error") else "complete" + ) lr = result.get("last_reasoning") if isinstance(lr, str) and lr.strip(): last_reasoning = lr.strip() @@ -1652,12 +2034,19 @@ def _(rid, params: dict) -> dict: session["image_counter"] = session.get("image_counter", 0) + 1 img_dir = _hermes_home / "images" img_dir.mkdir(parents=True, exist_ok=True) - img_path = img_dir / f"clip_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}.png" + img_path = ( + img_dir + / f"clip_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}.png" + ) # Save-first: mirrors CLI keybinding path; more robust than has_image() precheck if not save_clipboard_image(img_path): session["image_counter"] = max(0, session["image_counter"] - 1) - msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard" + msg = ( + "Clipboard has image but extraction failed" + if has_clipboard_image() + else "No image found in clipboard" + ) return _ok(rid, {"attached": False, "message": msg}) session.setdefault("attached_images", []).append(str(img_path)) @@ -1681,7 +2070,12 @@ def _(rid, params: dict) -> dict: if not raw: return _err(rid, 4015, "path required") try: - from cli import _IMAGE_EXTENSIONS, _detect_file_drop, _resolve_attachment_path, _split_path_input + from cli import ( + _IMAGE_EXTENSIONS, + _detect_file_drop, + _resolve_attachment_path, + _split_path_input, + ) dropped = _detect_file_drop(raw) if dropped: @@ -1740,7 +2134,9 @@ def _(rid, params: dict) -> dict: }, ) - text = f"[User attached file: {drop_path}]" + (f"\n{remainder}" if remainder else "") + text = f"[User attached file: {drop_path}]" + ( + f"\n{remainder}" if remainder else "" + ) return _ok( rid, { @@ -1769,14 +2165,31 @@ def _(rid, params: dict) -> dict: session_tokens = _set_session_context(task_id) try: from run_agent import AIAgent - result = AIAgent(**_background_agent_kwargs(session["agent"], task_id)).run_conversation( + + result = AIAgent( + **_background_agent_kwargs(session["agent"], task_id) + ).run_conversation( user_message=text, task_id=task_id, ) - _emit("background.complete", parent, {"task_id": task_id, - "text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) + _emit( + "background.complete", + parent, + { + "task_id": task_id, + "text": ( + result.get("final_response", str(result)) + if isinstance(result, dict) + else str(result) + ), + }, + ) except Exception as e: - _emit("background.complete", parent, {"task_id": task_id, "text": f"error: {e}"}) + _emit( + "background.complete", + parent, + {"task_id": task_id, "text": f"error: {e}"}, + ) finally: _clear_session_context(session_tokens) @@ -1798,9 +2211,25 @@ def _(rid, params: dict) -> dict: session_tokens = _set_session_context(session["session_key"]) try: from run_agent import AIAgent - result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", - max_iterations=8, enabled_toolsets=[]).run_conversation(text, conversation_history=snapshot) - _emit("btw.complete", sid, {"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) + + result = AIAgent( + model=_resolve_model(), + quiet_mode=True, + platform="tui", + max_iterations=8, + enabled_toolsets=[], + ).run_conversation(text, conversation_history=snapshot) + _emit( + "btw.complete", + sid, + { + "text": ( + result.get("final_response", str(result)) + if isinstance(result, dict) + else str(result) + ) + }, + ) except Exception as e: _emit("btw.complete", sid, {"text": f"error: {e}"}) finally: @@ -1812,6 +2241,7 @@ def _(rid, params: dict) -> dict: # ── Methods: respond ───────────────────────────────────────────────── + def _respond(rid, params, key): r = params.get("request_id", "") entry = _pending.get(r) @@ -1827,14 +2257,17 @@ def _respond(rid, params, key): def _(rid, params: dict) -> dict: return _respond(rid, params, "answer") + @method("sudo.respond") def _(rid, params: dict) -> dict: return _respond(rid, params, "password") + @method("secret.respond") def _(rid, params: dict) -> dict: return _respond(rid, params, "value") + @method("approval.respond") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) @@ -1842,14 +2275,24 @@ def _(rid, params: dict) -> dict: return err try: from tools.approval import resolve_gateway_approval - return _ok(rid, {"resolved": resolve_gateway_approval( - session["session_key"], params.get("choice", "deny"), resolve_all=params.get("all", False))}) + + return _ok( + rid, + { + "resolved": resolve_gateway_approval( + session["session_key"], + params.get("choice", "deny"), + resolve_all=params.get("all", False), + ) + }, + ) except Exception as e: return _err(rid, 5004, str(e)) # ── Methods: config ────────────────────────────────────────────────── + @method("config.set") def _(rid, params: dict) -> dict: key, value = params.get("key", ""), params.get("value", "") @@ -1870,19 +2313,29 @@ def _(rid, params: dict) -> dict: # with the gateway's running-agent /model guard. if session.get("running"): return _err( - rid, 4009, + rid, + 4009, "session busy — /interrupt the current turn before switching models", ) - result = _apply_model_switch(params.get("session_id", ""), session, value) + result = _apply_model_switch( + params.get("session_id", ""), session, value + ) else: result = _apply_model_switch("", {"agent": None}, value) - return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]}) + return _ok( + rid, + {"key": key, "value": result["value"], "warning": result["warning"]}, + ) except Exception as e: return _err(rid, 5001, str(e)) if key == "verbose": cycle = ["off", "new", "all", "verbose"] - cur = session.get("tool_progress_mode", _load_tool_progress_mode()) if session else _load_tool_progress_mode() + cur = ( + session.get("tool_progress_mode", _load_tool_progress_mode()) + if session + else _load_tool_progress_mode() + ) if value and value != "cycle": nv = str(value).strip().lower() if nv not in cycle: @@ -1970,7 +2423,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown thinking_mode: {value}") _write_config_key("display.thinking_mode", nv) # Backward compatibility bridge: keep details_mode aligned. - _write_config_key("display.details_mode", "expanded" if nv == "full" else "collapsed") + _write_config_key( + "display.details_mode", "expanded" if nv == "full" else "collapsed" + ) return _ok(rid, {"key": key, "value": nv}) if key in ("compact", "statusbar"): @@ -2008,7 +2463,9 @@ def _(rid, params: dict) -> dict: _write_config_key("display.personality", pname) _write_config_key("agent.system_prompt", new_prompt) nv = str(value or "default") - history_reset, info = _apply_personality_to_session(sid_key, session, new_prompt) + history_reset, info = _apply_personality_to_session( + sid_key, session, new_prompt + ) else: _write_config_key(f"display.{key}", value) nv = value @@ -2032,31 +2489,56 @@ def _(rid, params: dict) -> dict: if key == "provider": try: from hermes_cli.models import list_available_providers, normalize_provider + model = _resolve_model() parts = model.split("/", 1) - return _ok(rid, {"model": model, "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown", - "providers": list_available_providers()}) + return _ok( + rid, + { + "model": model, + "provider": ( + normalize_provider(parts[0]) if len(parts) > 1 else "unknown" + ), + "providers": list_available_providers(), + }, + ) except Exception as e: return _err(rid, 5013, str(e)) if key == "profile": from hermes_constants import display_hermes_home + return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()}) if key == "full": return _ok(rid, {"config": _load_cfg()}) if key == "prompt": return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": - return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) + return _ok( + rid, {"value": _load_cfg().get("display", {}).get("skin", "default")} + ) if key == "personality": - return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")}) + return _ok( + rid, {"value": _load_cfg().get("display", {}).get("personality", "default")} + ) if key == "reasoning": cfg = _load_cfg() effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") - display = "show" if bool(cfg.get("display", {}).get("show_reasoning", False)) else "hide" + display = ( + "show" + if bool(cfg.get("display", {}).get("show_reasoning", False)) + else "hide" + ) return _ok(rid, {"value": effort, "display": display}) if key == "details_mode": allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) - raw = str(_load_cfg().get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + raw = ( + str( + _load_cfg().get("display", {}).get("details_mode", "collapsed") + or "collapsed" + ) + .strip() + .lower() + ) nv = raw if raw in allowed_dm else "collapsed" return _ok(rid, {"value": nv}) if key == "thinking_mode": @@ -2066,7 +2548,14 @@ def _(rid, params: dict) -> dict: if raw in allowed_tm: nv = raw else: - dm = str(cfg.get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + dm = ( + str( + cfg.get("display", {}).get("details_mode", "collapsed") + or "collapsed" + ) + .strip() + .lower() + ) nv = "full" if dm == "expanded" else "collapsed" return _ok(rid, {"value": nv}) if key == "compact": @@ -2078,7 +2567,9 @@ def _(rid, params: dict) -> dict: if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: - return _ok(rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0}) + return _ok( + rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0} + ) except Exception: return _ok(rid, {"mtime": 0}) return _err(rid, 4002, f"unknown config key: {key}") @@ -2088,6 +2579,7 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from hermes_cli.main import _has_any_provider_configured + return _ok(rid, {"provider_configured": bool(_has_any_provider_configured())}) except Exception as e: return _err(rid, 5016, str(e)) @@ -2095,10 +2587,12 @@ def _(rid, params: dict) -> dict: # ── Methods: tools & system ────────────────────────────────────────── + @method("process.stop") def _(rid, params: dict) -> dict: try: from tools.process_registry import process_registry + return _ok(rid, {"killed": process_registry.kill_all()}) except Exception as e: return _err(rid, 5010, str(e)) @@ -2109,6 +2603,7 @@ def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id", "")) try: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools + shutdown_mcp_servers() discover_mcp_tools() if session: @@ -2121,9 +2616,17 @@ def _(rid, params: dict) -> dict: return _err(rid, 5015, str(e)) -_TUI_HIDDEN: frozenset[str] = frozenset({ - "sethome", "set-home", "update", "commands", "status", "approve", "deny", -}) +_TUI_HIDDEN: frozenset[str] = frozenset( + { + "sethome", + "set-home", + "update", + "commands", + "status", + "approve", + "deny", + } +) _TUI_EXTRA: list[tuple[str, str, str]] = [ ("/compact", "Toggle compact display mode", "TUI"), @@ -2133,16 +2636,26 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [ # Commands that queue messages onto _pending_input in the CLI. # In the TUI the slash worker subprocess has no reader for that queue, # so slash.exec rejects them → TUI falls through to command.dispatch. -_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({ - "retry", "queue", "q", "steer", "plan", -}) +_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( + { + "retry", + "queue", + "q", + "steer", + "plan", + } +) @method("commands.catalog") def _(rid, params: dict) -> dict: """Registry-backed slash metadata for the TUI — categorized, no aliases.""" try: - from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description + from hermes_cli.commands import ( + COMMAND_REGISTRY, + SUBCOMMANDS, + _build_description, + ) all_pairs: list[list[str]] = [] canon: dict[str, str] = {} @@ -2206,6 +2719,7 @@ def _(rid, params: dict) -> dict: skill_count = 0 try: from agent.skill_commands import scan_skill_commands + for k, info in sorted(scan_skill_commands().items()): d = str(info.get("description", "Skill")) all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")]) @@ -2217,14 +2731,17 @@ def _(rid, params: dict) -> dict: categories.append({"name": cat, "pairs": cat_map[cat]}) sub = {k: v[:] for k, v in SUBCOMMANDS.items()} - return _ok(rid, { - "pairs": all_pairs, - "sub": sub, - "canon": canon, - "categories": categories, - "skill_count": skill_count, - "warning": warning, - }) + return _ok( + rid, + { + "pairs": all_pairs, + "sub": sub, + "canon": canon, + "categories": categories, + "skill_count": skill_count, + "warning": warning, + }, + ) except Exception as e: return _err(rid, 5020, str(e)) @@ -2265,7 +2782,9 @@ def _(rid, params: dict) -> dict: ) parts = [r.stdout or "", r.stderr or ""] out = "\n".join(p for p in parts if p).strip() or "(no output)" - return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) + return _ok( + rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]} + ) except subprocess.TimeoutExpired: return _err(rid, 5016, "cli.exec: timeout") except Exception as e: @@ -2276,9 +2795,17 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from hermes_cli.commands import resolve_command + r = resolve_command(params.get("name", "")) if r: - return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) + return _ok( + rid, + { + "canonical": r.name, + "description": r.description, + "category": r.category, + }, + ) return _err(rid, 4011, f"unknown command: {params.get('name')}") except Exception as e: return _err(rid, 5012, str(e)) @@ -2287,6 +2814,7 @@ def _(rid, params: dict) -> dict: def _resolve_name(name: str) -> str: try: from hermes_cli.commands import resolve_command + r = resolve_command(name) return r.name if r else name except Exception: @@ -2305,16 +2833,31 @@ def _(rid, params: dict) -> dict: if name in qcmds: qc = qcmds[name] if qc.get("type") == "exec": - r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30) - output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] + r = subprocess.run( + qc.get("command", ""), + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + output = ( + (r.stdout or "") + + ("\n" if r.stdout and r.stderr else "") + + (r.stderr or "") + ).strip()[:4000] if r.returncode != 0: - return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") + return _err( + rid, + 4018, + output or f"quick command failed with exit code {r.returncode}", + ) return _ok(rid, {"type": "exec", "output": output}) if qc.get("type") == "alias": return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) try: from hermes_cli.plugins import get_plugin_command_handler + handler = get_plugin_command_handler(name) if handler: return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")}) @@ -2322,13 +2865,26 @@ def _(rid, params: dict) -> dict: pass try: - from agent.skill_commands import scan_skill_commands, build_skill_invocation_message + from agent.skill_commands import ( + scan_skill_commands, + build_skill_invocation_message, + ) + cmds = scan_skill_commands() key = f"/{name}" if key in cmds: - msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") + msg = build_skill_invocation_message( + key, arg, task_id=session.get("session_key", "") if session else "" + ) if msg: - return _ok(rid, {"type": "skill", "message": msg, "name": cmds[key].get("name", name)}) + return _ok( + rid, + { + "type": "skill", + "message": msg, + "name": cmds[key].get("name", name), + }, + ) except Exception: pass @@ -2345,7 +2901,9 @@ def _(rid, params: dict) -> dict: if not session: return _err(rid, 4001, "no active session to retry") if session.get("running"): - return _err(rid, 4009, "session busy — /interrupt the current turn before /retry") + return _err( + rid, 4009, "session busy — /interrupt the current turn before /retry" + ) history = session.get("history", []) if not history: return _err(rid, 4018, "no previous user message to retry") @@ -2360,7 +2918,9 @@ def _(rid, params: dict) -> dict: content = history[last_user_idx].get("content", "") if isinstance(content, list): content = " ".join( - p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" ) if not content: return _err(rid, 4018, "last user message is empty") @@ -2379,7 +2939,13 @@ def _(rid, params: dict) -> dict: try: accepted = agent.steer(arg) if accepted: - return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}"}) + return _ok( + rid, + { + "type": "exec", + "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}", + }, + ) except Exception: pass # Fallback: no active run, treat as next-turn message @@ -2387,11 +2953,16 @@ def _(rid, params: dict) -> dict: if name == "plan": try: - from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path + from agent.skill_commands import ( + build_skill_invocation_message as _bsim, + build_plan_path, + ) + user_instruction = arg or "" plan_path = build_plan_path(user_instruction) msg = _bsim( - "/plan", user_instruction, + "/plan", + user_instruction, task_id=session.get("session_key", "") if session else "", runtime_note=( "Save the markdown plan with write_file to this exact relative path " @@ -2410,6 +2981,7 @@ def _(rid, params: dict) -> dict: _paste_counter = 0 + @method("paste.collapse") def _(rid, params: dict) -> dict: global _paste_counter @@ -2418,20 +2990,28 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "empty paste") _paste_counter += 1 - line_count = text.count('\n') + 1 + line_count = text.count("\n") + 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) from datetime import datetime - paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" + + paste_file = ( + paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" + ) paste_file.write_text(text, encoding="utf-8") - placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" - return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}) + placeholder = ( + f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" + ) + return _ok( + rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count} + ) # ── Methods: complete ───────────────────────────────────────────────── + @method("complete.path") def _(rid, params: dict) -> dict: word = params.get("word", "") @@ -2507,7 +3087,13 @@ def _(rid, params: dict) -> dict: else: text = rel + suffix - items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""}) + items.append( + { + "text": text, + "display": entry + suffix, + "meta": "dir" if is_dir else "", + } + ) if len(items) >= 30: break except Exception as e: @@ -2529,22 +3115,40 @@ def _(rid, params: dict) -> dict: from agent.skill_commands import get_skill_commands - completer = SlashCommandCompleter(skill_commands_provider=lambda: get_skill_commands()) + completer = SlashCommandCompleter( + skill_commands_provider=lambda: get_skill_commands() + ) doc = Document(text, len(text)) items = [ - {"text": c.text, "display": c.display or c.text, - "meta": to_plain_text(c.display_meta) if c.display_meta else ""} + { + "text": c.text, + "display": c.display or c.text, + "meta": to_plain_text(c.display_meta) if c.display_meta else "", + } for c in completer.get_completions(doc, None) ][:30] text_lower = text.lower() extras = [ - {"text": "/compact", "display": "/compact", "meta": "Toggle compact display mode"}, - {"text": "/logs", "display": "/logs", "meta": "Show recent gateway log lines"}, + { + "text": "/compact", + "display": "/compact", + "meta": "Toggle compact display mode", + }, + { + "text": "/logs", + "display": "/logs", + "meta": "Show recent gateway log lines", + }, ] for extra in extras: - if extra["text"].startswith(text_lower) and not any(item["text"] == extra["text"] for item in items): + if extra["text"].startswith(text_lower) and not any( + item["text"] == extra["text"] for item in items + ): items.append(extra) - return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) + return _ok( + rid, + {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}, + ) except Exception as e: return _err(rid, 5020, str(e)) @@ -2567,11 +3171,24 @@ def _(rid, params: dict) -> dict: # TTS, embeddings, rerankers, image/video generators). providers = list_authenticated_providers( current_provider=current_provider, - user_providers=cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}, - custom_providers=cfg.get("custom_providers") if isinstance(cfg.get("custom_providers"), list) else [], + user_providers=( + cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} + ), + custom_providers=( + cfg.get("custom_providers") + if isinstance(cfg.get("custom_providers"), list) + else [] + ), max_models=50, ) - return _ok(rid, {"providers": providers, "model": current_model, "provider": current_provider}) + return _ok( + rid, + { + "providers": providers, + "model": current_model, + "provider": current_provider, + }, + ) except Exception as e: return _err(rid, 5033, str(e)) @@ -2584,7 +3201,11 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: parts = command.lstrip("/").split(None, 1) if not parts: return "" - name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent") + name, arg, agent = ( + parts[0], + (parts[1].strip() if len(parts) > 1 else ""), + session.get("agent"), + ) # Reject agent-mutating commands during an in-flight turn. These # all do read-then-mutate on live agent/session state that the @@ -2593,9 +3214,7 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: # runner's running-agent /model guard. _MUTATES_WHILE_RUNNING = {"model", "personality", "prompt", "compress"} if name in _MUTATES_WHILE_RUNNING and session.get("running"): - return ( - f"session busy — /interrupt the current turn before running /{name}" - ) + return f"session busy — /interrupt the current turn before running /{name}" try: if name == "model" and arg and agent: @@ -2624,6 +3243,7 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: agent.reload_mcp_tools() elif name == "stop": from tools.process_registry import process_registry + process_registry.kill_all() except Exception as e: return f"live session sync failed: {e}" @@ -2650,20 +3270,28 @@ def _(rid, params: dict) -> dict: _cmd_base = _cmd_parts[0] if _cmd_parts else "" if _cmd_base in _PENDING_INPUT_COMMANDS: - return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}") + return _err( + rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}" + ) try: from agent.skill_commands import get_skill_commands + _cmd_key = f"/{_cmd_base}" if _cmd_key in get_skill_commands(): - return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") + return _err( + rid, 4018, f"skill command: use command.dispatch for {_cmd_key}" + ) except Exception: pass worker = session.get("slash_worker") if not worker: try: - worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model())) + worker = _SlashWorker( + session["session_key"], + getattr(session.get("agent"), "model", _resolve_model()), + ) session["slash_worker"] = worker except Exception as e: return _err(rid, 5030, f"slash worker start failed: {e}") @@ -2686,6 +3314,7 @@ def _(rid, params: dict) -> dict: # ── Methods: voice ─────────────────────────────────────────────────── + @method("voice.toggle") def _(rid, params: dict) -> dict: action = params.get("action", "status") @@ -2693,7 +3322,14 @@ def _(rid, params: dict) -> dict: env = os.environ.get("HERMES_VOICE", "").strip() if env in {"0", "1"}: return _ok(rid, {"enabled": env == "1"}) - return _ok(rid, {"enabled": bool(_load_cfg().get("display", {}).get("voice_enabled", False))}) + return _ok( + rid, + { + "enabled": bool( + _load_cfg().get("display", {}).get("voice_enabled", False) + ) + }, + ) if action in ("on", "off"): enabled = action == "on" os.environ["HERMES_VOICE"] = "1" if enabled else "0" @@ -2708,14 +3344,18 @@ def _(rid, params: dict) -> dict: try: if action == "start": from hermes_cli.voice import start_recording + start_recording() return _ok(rid, {"status": "recording"}) if action == "stop": from hermes_cli.voice import stop_and_transcribe + return _ok(rid, {"text": stop_and_transcribe() or ""}) return _err(rid, 4019, f"unknown voice action: {action}") except ImportError: - return _err(rid, 5025, "voice module not available — install audio dependencies") + return _err( + rid, 5025, "voice module not available — install audio dependencies" + ) except Exception as e: return _err(rid, 5025, str(e)) @@ -2727,6 +3367,7 @@ def _(rid, params: dict) -> dict: return _err(rid, 4020, "text required") try: from hermes_cli.voice import speak_text + threading.Thread(target=speak_text, args=(text,), daemon=True).start() return _ok(rid, {"status": "speaking"}) except ImportError: @@ -2737,31 +3378,57 @@ def _(rid, params: dict) -> dict: # ── Methods: insights ──────────────────────────────────────────────── + @method("insights.get") def _(rid, params: dict) -> dict: days = params.get("days", 30) try: cutoff = time.time() - days * 86400 - rows = [s for s in _get_db().list_sessions_rich(limit=500) if (s.get("started_at") or 0) >= cutoff] - return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) + rows = [ + s + for s in _get_db().list_sessions_rich(limit=500) + if (s.get("started_at") or 0) >= cutoff + ] + return _ok( + rid, + { + "days": days, + "sessions": len(rows), + "messages": sum(s.get("message_count", 0) for s in rows), + }, + ) except Exception as e: return _err(rid, 5017, str(e)) # ── Methods: rollback ──────────────────────────────────────────────── + @method("rollback.list") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err try: + def go(mgr, cwd): if not mgr.enabled: return _ok(rid, {"enabled": False, "checkpoints": []}) - return _ok(rid, {"enabled": True, "checkpoints": [ - {"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")} - for c in mgr.list_checkpoints(cwd)]}) + return _ok( + rid, + { + "enabled": True, + "checkpoints": [ + { + "hash": c.get("hash", ""), + "timestamp": c.get("timestamp", ""), + "message": c.get("message", ""), + } + for c in mgr.list_checkpoints(cwd) + ], + }, + ) + return _with_checkpoints(session, go) except Exception as e: return _err(rid, 5020, str(e)) @@ -2782,8 +3449,13 @@ def _(rid, params: dict) -> dict: # rollback (version-matches path). A file-scoped rollback only # touches disk, so we allow it. if not file_path and session.get("running"): - return _err(rid, 4009, "session busy — /interrupt the current turn before full rollback.restore") + return _err( + rid, + 4009, + "session busy — /interrupt the current turn before full rollback.restore", + ) try: + def go(mgr, cwd): resolved = _resolve_checkpoint_hash(mgr, cwd, target) result = mgr.restore(cwd, resolved, file_path=file_path or None) @@ -2798,7 +3470,9 @@ def _(rid, params: dict) -> dict: history.pop() removed += 1 if removed: - session["history_version"] = int(session.get("history_version", 0)) + 1 + session["history_version"] = ( + int(session.get("history_version", 0)) + 1 + ) result["history_removed"] = removed return result @@ -2816,7 +3490,10 @@ def _(rid, params: dict) -> dict: if not target: return _err(rid, 4014, "hash required") try: - r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) + r = _with_checkpoints( + session, + lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target)), + ) raw = r.get("diff", "")[:4000] payload = {"stat": r.get("stat", ""), "diff": raw} rendered = render_diff(raw, session.get("cols", 80)) @@ -2829,6 +3506,7 @@ def _(rid, params: dict) -> dict: # ── Methods: browser / plugins / cron / skills ─────────────────────── + @method("browser.manage") def _(rid, params: dict) -> dict: action = params.get("action", "status") @@ -2845,10 +3523,11 @@ def _(rid, params: dict) -> dict: parsed = urlparse(url if "://" in url else f"http://{url}") if parsed.scheme not in {"http", "https", "ws", "wss"}: return _err(rid, 4015, f"unsupported browser url: {url}") - probe_root = ( - f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}" - ) - probe_urls = [f"{probe_root.rstrip('/')}/json/version", f"{probe_root.rstrip('/')}/json"] + probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}" + probe_urls = [ + f"{probe_root.rstrip('/')}/json/version", + f"{probe_root.rstrip('/')}/json", + ] ok = False for probe in probe_urls: try: @@ -2870,6 +3549,7 @@ def _(rid, params: dict) -> dict: os.environ.pop("BROWSER_CDP_URL", None) try: from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() except Exception: pass @@ -2881,9 +3561,20 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from hermes_cli.plugins import get_plugin_manager - return _ok(rid, {"plugins": [ - {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} - for n, i in get_plugin_manager()._plugins.items()]}) + + return _ok( + rid, + { + "plugins": [ + { + "name": n, + "version": getattr(i, "version", "?"), + "enabled": getattr(i, "enabled", True), + } + for n, i in get_plugin_manager()._plugins.items() + ] + }, + ) except Exception as e: return _err(rid, 5032, str(e)) @@ -2897,27 +3588,31 @@ def _(rid, params: dict) -> dict: masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)" base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "") - sections = [{ - "title": "Model", - "rows": [ - ["Model", model], - ["Base URL", base_url or "(default)"], - ["API Key", masked], - ] - }, { - "title": "Agent", - "rows": [ - ["Max Turns", str(cfg.get("max_turns", 25))], - ["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"], - ["Verbose", str(cfg.get("verbose", False))], - ] - }, { - "title": "Environment", - "rows": [ - ["Working Dir", os.getcwd()], - ["Config File", str(_hermes_home / "config.yaml")], - ] - }] + sections = [ + { + "title": "Model", + "rows": [ + ["Model", model], + ["Base URL", base_url or "(default)"], + ["API Key", masked], + ], + }, + { + "title": "Agent", + "rows": [ + ["Max Turns", str(cfg.get("max_turns", 25))], + ["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"], + ["Verbose", str(cfg.get("verbose", False))], + ], + }, + { + "title": "Environment", + "rows": [ + ["Working Dir", os.getcwd()], + ["Config File", str(_hermes_home / "config.yaml")], + ], + }, + ] return _ok(rid, {"sections": sections}) except Exception as e: return _err(rid, 5030, str(e)) @@ -2927,21 +3622,28 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) - enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) + enabled = ( + set(getattr(session["agent"], "enabled_toolsets", []) or []) + if session + else set(_load_enabled_toolsets() or []) + ) items = [] for name in sorted(get_all_toolsets().keys()): info = get_toolset_info(name) if not info: continue - items.append({ - "name": name, - "description": info["description"], - "tool_count": info["tool_count"], - "enabled": name in enabled if enabled else True, - "tools": info["resolved_tools"], - }) + items.append( + { + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + "tools": info["resolved_tools"], + } + ) return _ok(rid, {"toolsets": items}) except Exception as e: return _err(rid, 5031, str(e)) @@ -2953,7 +3655,11 @@ def _(rid, params: dict) -> dict: from model_tools import get_toolset_for_tool, get_tool_definitions session = _sessions.get(params.get("session_id", "")) - enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets() + enabled = ( + getattr(session["agent"], "enabled_toolsets", None) + if session + else _load_enabled_toolsets() + ) tools = get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True) sections = {} @@ -2961,16 +3667,24 @@ def _(rid, params: dict) -> dict: name = tool["function"]["name"] desc = str(tool["function"].get("description", "") or "").split("\n")[0] if ". " in desc: - desc = desc[:desc.index(". ") + 1] - sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append({ - "name": name, - "description": desc, - }) + desc = desc[: desc.index(". ") + 1] + sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append( + { + "name": name, + "description": desc, + } + ) - return _ok(rid, { - "sections": [{"name": name, "tools": rows} for name, rows in sorted(sections.items())], - "total": len(tools), - }) + return _ok( + rid, + { + "sections": [ + {"name": name, "tools": rows} + for name, rows in sorted(sections.items()) + ], + "total": len(tools), + }, + ) except Exception as e: return _err(rid, 5034, str(e)) @@ -2978,7 +3692,9 @@ def _(rid, params: dict) -> dict: @method("tools.configure") def _(rid, params: dict) -> dict: action = str(params.get("action", "") or "").strip().lower() - targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()] + targets = [ + str(name).strip() for name in params.get("names", []) or [] if str(name).strip() + ] if action not in {"disable", "enable"}: return _err(rid, 4017, f"unknown tools action: {action}") if not targets: @@ -2995,7 +3711,9 @@ def _(rid, params: dict) -> dict: ) cfg = load_config() - valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() + valid_toolsets = { + ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS + } | _get_plugin_toolset_keys() toolset_targets = [name for name in targets if ":" not in name] mcp_targets = [name for name in targets if ":" in name] unknown = [name for name in toolset_targets if name not in valid_toolsets] @@ -3004,25 +3722,38 @@ def _(rid, params: dict) -> dict: if toolset_targets: _apply_toolset_change(cfg, "cli", toolset_targets, action) - missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() + missing_servers = ( + _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() + ) save_config(cfg) session = _sessions.get(params.get("session_id", "")) - info = _reset_session_agent(params.get("session_id", ""), session) if session else None - enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) + info = ( + _reset_session_agent(params.get("session_id", ""), session) + if session + else None + ) + enabled = sorted( + _get_platform_tools(load_config(), "cli", include_default_mcp_servers=False) + ) changed = [ - name for name in targets - if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers) + name + for name in targets + if name not in unknown + and (":" not in name or name.split(":", 1)[0] not in missing_servers) ] - return _ok(rid, { - "changed": changed, - "enabled_toolsets": enabled, - "info": info, - "missing_servers": sorted(missing_servers), - "reset": bool(session), - "unknown": unknown, - }) + return _ok( + rid, + { + "changed": changed, + "enabled_toolsets": enabled, + "info": info, + "missing_servers": sorted(missing_servers), + "reset": bool(session), + "unknown": unknown, + }, + ) except Exception as e: return _err(rid, 5035, str(e)) @@ -3031,20 +3762,27 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) - enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) + enabled = ( + set(getattr(session["agent"], "enabled_toolsets", []) or []) + if session + else set(_load_enabled_toolsets() or []) + ) items = [] for name in sorted(get_all_toolsets().keys()): info = get_toolset_info(name) if not info: continue - items.append({ - "name": name, - "description": info["description"], - "tool_count": info["tool_count"], - "enabled": name in enabled if enabled else True, - }) + items.append( + { + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + } + ) return _ok(rid, {"toolsets": items}) except Exception as e: return _err(rid, 5032, str(e)) @@ -3054,15 +3792,22 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from tools.process_registry import process_registry + procs = process_registry.list_sessions() - return _ok(rid, { - "processes": [{ - "session_id": p["session_id"], - "command": p["command"][:80], - "status": p["status"], - "uptime": p["uptime_seconds"], - } for p in procs] - }) + return _ok( + rid, + { + "processes": [ + { + "session_id": p["session_id"], + "command": p["command"][:80], + "status": p["status"], + "uptime": p["uptime_seconds"], + } + for p in procs + ] + }, + ) except Exception as e: return _err(rid, 5033, str(e)) @@ -3072,11 +3817,21 @@ def _(rid, params: dict) -> dict: action, jid = params.get("action", "list"), params.get("name", "") try: from tools.cronjob_tools import cronjob + if action == "list": return _ok(rid, json.loads(cronjob(action="list"))) if action == "add": - return _ok(rid, json.loads(cronjob(action="create", name=jid, - schedule=params.get("schedule", ""), prompt=params.get("prompt", "")))) + return _ok( + rid, + json.loads( + cronjob( + action="create", + name=jid, + schedule=params.get("schedule", ""), + prompt=params.get("prompt", ""), + ) + ), + ) if action in ("remove", "pause", "resume"): return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) return _err(rid, 4016, f"unknown cron action: {action}") @@ -3090,23 +3845,53 @@ def _(rid, params: dict) -> dict: try: if action == "list": from hermes_cli.banner import get_available_skills + return _ok(rid, {"skills": get_available_skills()}) if action == "search": - from hermes_cli.skills_hub import unified_search, GitHubAuth, create_source_router - raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or [] - return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]}) + from hermes_cli.skills_hub import ( + unified_search, + GitHubAuth, + create_source_router, + ) + + raw = ( + unified_search( + query, + create_source_router(GitHubAuth()), + source_filter="all", + limit=20, + ) + or [] + ) + return _ok( + rid, + { + "results": [ + {"name": r.name, "description": r.description} for r in raw + ] + }, + ) if action == "install": from hermes_cli.skills_hub import do_install + class _Q: - def print(self, *a, **k): pass + def print(self, *a, **k): + pass + do_install(query, skip_confirm=True, console=_Q()) return _ok(rid, {"installed": True, "name": query}) if action == "browse": from hermes_cli.skills_hub import browse_skills - pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1) - return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20)))) + + pg = int(params.get("page", 0) or 0) or ( + int(query) if query.isdigit() else 1 + ) + return _ok( + rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20))) + ) if action == "inspect": from hermes_cli.skills_hub import inspect_skill + return _ok(rid, {"info": inspect_skill(query) or {}}) return _err(rid, 4017, f"unknown skills action: {action}") except Exception as e: @@ -3115,6 +3900,7 @@ def _(rid, params: dict) -> dict: # ── Methods: shell ─────────────────────────────────────────────────── + @method("shell.exec") def _(rid, params: dict) -> dict: cmd = params.get("command", "") @@ -3122,14 +3908,26 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "empty command") try: from tools.approval import detect_dangerous_command + is_dangerous, _, desc = detect_dangerous_command(cmd) if is_dangerous: - return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") + return _err( + rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands." + ) except ImportError: pass try: - r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()) - return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) + r = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd() + ) + return _ok( + rid, + { + "stdout": r.stdout[-4000:], + "stderr": r.stderr[-2000:], + "code": r.returncode, + }, + ) except subprocess.TimeoutExpired: return _err(rid, 5002, "command timed out (30s)") except Exception as e: diff --git a/ui-tui/src/__tests__/subagentTree.test.ts b/ui-tui/src/__tests__/subagentTree.test.ts new file mode 100644 index 000000000..649b791ce --- /dev/null +++ b/ui-tui/src/__tests__/subagentTree.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from 'vitest' + +import { + buildSubagentTree, + descendantIds, + flattenTree, + fmtCost, + fmtTokens, + formatSummary, + hotnessBucket, + peakHotness, + sparkline, + treeTotals, + widthByDepth +} from '../lib/subagentTree.js' +import type { SubagentProgress } from '../types.js' + +const makeItem = (overrides: Partial & Pick): SubagentProgress => ({ + depth: 0, + goal: overrides.id, + notes: [], + parentId: null, + status: 'running', + taskCount: 1, + thinking: [], + toolCount: 0, + tools: [], + ...overrides +}) + +describe('aggregate: tokens, cost, files, hotness', () => { + it('sums tokens and cost across subtree', () => { + const items = [ + makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }), + makeItem({ + costUsd: 0.005, + depth: 1, + id: 'c1', + index: 0, + inputTokens: 500, + outputTokens: 100, + parentId: 'p' + }), + makeItem({ + costUsd: 0.008, + depth: 1, + id: 'c2', + index: 1, + inputTokens: 300, + outputTokens: 200, + parentId: 'p' + }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate).toMatchObject({ + costUsd: 0.023, + inputTokens: 1800, + outputTokens: 800 + }) + }) + + it('counts files read + written across subtree', () => { + const items = [ + makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }), + makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate.filesTouched).toBe(3) + }) + + it('hotness = totalTools / totalDuration', () => { + const items = [ + makeItem({ + durationSeconds: 10, + id: 'p', + index: 0, + status: 'completed', + toolCount: 20 + }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate.hotness).toBeCloseTo(2) + }) + + it('hotness is zero when duration is zero', () => { + const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })] + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate.hotness).toBe(0) + }) +}) + +describe('hotnessBucket + peakHotness', () => { + it('peakHotness walks subtree', () => { + const items = [ + makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }), + makeItem({ + depth: 1, + durationSeconds: 1, + id: 'c', + index: 0, + parentId: 'p', + status: 'completed', + toolCount: 5 + }) + ] + + const tree = buildSubagentTree(items) + expect(peakHotness(tree)).toBeGreaterThan(2) + }) + + it('hotnessBucket clamps and normalizes', () => { + expect(hotnessBucket(0, 10, 4)).toBe(0) + expect(hotnessBucket(10, 10, 4)).toBe(3) + expect(hotnessBucket(5, 10, 4)).toBe(2) + expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped + expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero + }) +}) + +describe('fmtCost + fmtTokens', () => { + it('fmtCost handles ranges', () => { + expect(fmtCost(0)).toBe('') + expect(fmtCost(0.001)).toBe('<$0.01') + expect(fmtCost(0.42)).toBe('$0.42') + expect(fmtCost(1.23)).toBe('$1.23') + expect(fmtCost(12.5)).toBe('$12.5') + }) + + it('fmtTokens handles ranges', () => { + expect(fmtTokens(0)).toBe('0') + expect(fmtTokens(542)).toBe('542') + expect(fmtTokens(1234)).toBe('1.2k') + expect(fmtTokens(45678)).toBe('46k') + }) +}) + +describe('formatSummary with tokens + cost', () => { + it('includes token + cost when present', () => { + expect( + formatSummary({ + activeCount: 0, + costUsd: 0.42, + descendantCount: 3, + filesTouched: 0, + hotness: 0, + inputTokens: 8000, + maxDepthFromHere: 2, + outputTokens: 2000, + totalDuration: 30, + totalTools: 14 + }) + ).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42') + }) +}) + +describe('buildSubagentTree', () => { + it('returns empty list for empty input', () => { + expect(buildSubagentTree([])).toEqual([]) + }) + + it('treats flat list as top-level when no parentId is given', () => { + const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })] + + const tree = buildSubagentTree(items) + expect(tree).toHaveLength(3) + expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c']) + expect(tree.every(n => n.children.length === 0)).toBe(true) + }) + + it('nests children under their parent by subagent_id', () => { + const items = [ + makeItem({ id: 'parent', index: 0 }), + makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }), + makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' }) + ] + + const tree = buildSubagentTree(items) + expect(tree).toHaveLength(1) + expect(tree[0]!.children).toHaveLength(2) + expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2']) + }) + + it('builds multi-level nesting', () => { + const items = [ + makeItem({ id: 'p', index: 0 }), + makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }), + makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc') + expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2) + expect(tree[0]!.aggregate.descendantCount).toBe(2) + }) + + it('promotes orphaned children (missing parent) to top level', () => { + const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })] + + const tree = buildSubagentTree(items) + expect(tree).toHaveLength(2) + expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan']) + }) + + it('stable sort: children ordered by (depth, index) not insert order', () => { + const items = [ + makeItem({ id: 'p', index: 0 }), + makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }), + makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }), + makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3']) + }) +}) + +describe('aggregate', () => { + it('sums tool counts and durations across subtree', () => { + const items = [ + makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }), + makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }), + makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate).toMatchObject({ + activeCount: 0, + descendantCount: 2, + totalDuration: 16, + totalTools: 9 + }) + }) + + it('counts queued + running as active', () => { + const items = [ + makeItem({ id: 'p', index: 0, status: 'running' }), + makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }), + makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' }) + ] + + const tree = buildSubagentTree(items) + expect(tree[0]!.aggregate.activeCount).toBe(2) + }) +}) + +describe('widthByDepth', () => { + it('returns empty array for empty tree', () => { + expect(widthByDepth([])).toEqual([]) + }) + + it('tallies nodes at each depth', () => { + const items = [ + makeItem({ id: 'p1', index: 0 }), + makeItem({ id: 'p2', index: 1 }), + makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }), + makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }), + makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }), + makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' }) + ] + + expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1]) + }) +}) + +describe('treeTotals', () => { + it('folds a full tree into a single rollup', () => { + const items = [ + makeItem({ id: 'p1', index: 0, toolCount: 5 }), + makeItem({ id: 'p2', index: 1, toolCount: 2 }), + makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 }) + ] + + const totals = treeTotals(buildSubagentTree(items)) + expect(totals.descendantCount).toBe(3) + expect(totals.totalTools).toBe(10) + expect(totals.maxDepthFromHere).toBe(2) + }) + + it('returns zeros for empty tree', () => { + expect(treeTotals([])).toEqual({ + activeCount: 0, + costUsd: 0, + descendantCount: 0, + filesTouched: 0, + hotness: 0, + inputTokens: 0, + maxDepthFromHere: 0, + outputTokens: 0, + totalDuration: 0, + totalTools: 0 + }) + }) +}) + +describe('flattenTree + descendantIds', () => { + const items = [ + makeItem({ id: 'p', index: 0 }), + makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }), + makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }), + makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' }) + ] + + it('flattens in visit order (depth-first, pre-order)', () => { + const tree = buildSubagentTree(items) + expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2']) + }) + + it('collects descendant ids excluding the node itself', () => { + const tree = buildSubagentTree(items) + expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2']) + }) +}) + +describe('sparkline', () => { + it('returns empty string for empty input', () => { + expect(sparkline([])).toBe('') + }) + + it('renders zeroes as spaces (not bottom glyph)', () => { + expect(sparkline([0, 0])).toBe(' ') + }) + + it('scales to the max value', () => { + const out = sparkline([1, 8]) + expect(out).toHaveLength(2) + expect(out[1]).toBe('█') + }) + + it('sparse widths render as expected', () => { + const out = sparkline([2, 3, 7, 4]) + expect(out).toHaveLength(4) + expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true) + }) +}) + +describe('formatSummary', () => { + const emptyTotals = { + activeCount: 0, + costUsd: 0, + descendantCount: 0, + filesTouched: 0, + hotness: 0, + inputTokens: 0, + maxDepthFromHere: 0, + outputTokens: 0, + totalDuration: 0, + totalTools: 0 + } + + it('collapses zero-valued components', () => { + expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent') + }) + + it('emits rich summary with all pieces', () => { + expect( + formatSummary({ + ...emptyTotals, + activeCount: 2, + descendantCount: 7, + maxDepthFromHere: 3, + totalDuration: 134, + totalTools: 124 + }) + ).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index e5324e460..11127e7b0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 = 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((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('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': { diff --git a/ui-tui/src/app/delegationStore.ts b/ui-tui/src/app/delegationStore.ts new file mode 100644 index 000000000..aa50738ed --- /dev/null +++ b/ui-tui/src/app/delegationStore.ts @@ -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(buildState()) + +export const getDelegationState = () => $delegationState.get() + +export const patchDelegationState = (next: Partial) => + $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>({}) + +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 = { 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) +} diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 757c59131..f14c232f0 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -53,6 +53,8 @@ export interface GatewayProviderProps { } export interface OverlayState { + agents: boolean + agentsInitialHistoryIndex: number approval: ApprovalReq | null clarify: ClarifyReq | null confirm: ConfirmReq | null diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 06dbd27a7..60aa09c44 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -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(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 | ((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 + }) diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index ef547b8db..343d83c8d 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -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('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 ]`', + 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('spawn_tree.list', { + limit: 30, + session_id: ctx.sid ?? 'default' + }) + .then( + ctx.guarded(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 ') + } + + ctx.gateway.rpc('spawn_tree.load', { path }) + .then( + ctx.guarded(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 ` (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 (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', diff --git a/ui-tui/src/app/spawnHistoryStore.ts b/ui-tui/src/app/spawnHistoryStore.ts new file mode 100644 index 000000000..9adb2b59c --- /dev/null +++ b/ui-tui/src/app/spawnHistoryStore.ts @@ -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([]) +export const $spawnDiff = atom(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 + const s = (v: unknown) => (typeof v === 'string' ? v : undefined) + const n = (v: unknown) => (typeof v === 'number' ? v : undefined) + const arr = (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(o.filesRead), + filesWritten: arr(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(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(o.thinking) ?? []).filter(x => typeof x === 'string'), + toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0, + tools: (arr(o.tools) ?? []).filter(x => typeof x === 'string'), + toolsets: arr(o.toolsets) + } +} diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index bf9d2926c..07da79019 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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() + persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise 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) { - 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 } }) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 5c5f27849..9d3ccdf09 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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) diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx new file mode 100644 index 000000000..eb2586d31 --- /dev/null +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -0,0 +1,1036 @@ +import { Box, NoSelect, ScrollBox, Text, useInput, useStdout, type ScrollBoxHandle } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react' + +import { + $delegationState, + $overlaySectionsOpen, + applyDelegationStatus, + toggleOverlaySection +} from '../app/delegationStore.js' +import { patchOverlayState } from '../app/overlayStore.js' +import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js' +import { $turnState } from '../app/turnStore.js' +import type { GatewayClient } from '../gatewayClient.js' +import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' +import { + buildSubagentTree, + descendantIds, + flattenTree, + fmtCost, + fmtTokens, + formatSummary, + hotnessBucket, + peakHotness, + sparkline, + treeTotals, + widthByDepth +} from '../lib/subagentTree.js' +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { SubagentNode, SubagentProgress } from '../types.js' + +// ── Types + lookup tables ──────────────────────────────────────────── + +type SortMode = 'depth-first' | 'duration-desc' | 'status' | 'tools-desc' +type FilterMode = 'all' | 'failed' | 'leaf' | 'running' +type Status = SubagentProgress['status'] + +const SORT_ORDER: readonly SortMode[] = ['depth-first', 'tools-desc', 'duration-desc', 'status'] +const FILTER_ORDER: readonly FilterMode[] = ['all', 'running', 'failed', 'leaf'] + +const SORT_LABEL: Record = { + 'depth-first': 'spawn order', + 'duration-desc': 'slowest', + status: 'status', + 'tools-desc': 'busiest' +} + +const FILTER_LABEL: Record = { + all: 'all', + failed: 'failed', + leaf: 'leaves', + running: 'running' +} + +const STATUS_RANK: Record = { + failed: 0, + interrupted: 1, + running: 2, + queued: 3, + completed: 4 +} + +const SORT_COMPARATORS: Record number> = { + 'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index, + 'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools, + 'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration, + status: (a, b) => STATUS_RANK[a.item.status] - STATUS_RANK[b.item.status] +} + +const FILTER_PREDICATES: Record boolean> = { + all: () => true, + leaf: n => n.children.length === 0, + running: n => n.item.status === 'running' || n.item.status === 'queued', + failed: n => n.item.status === 'failed' || n.item.status === 'interrupted' +} + +const STATUS_GLYPH: Record string; glyph: string }> = { + running: { color: t => t.color.amber, glyph: '●' }, + queued: { color: t => t.color.dim, glyph: '○' }, + completed: { color: t => t.color.statusGood, glyph: '✓' }, + interrupted: { color: t => t.color.warn, glyph: '■' }, + failed: { color: t => t.color.error, glyph: '✗' } +} + +// Heatmap palette — cold → hot, resolved against the active theme. +const heatPalette = (t: Theme) => [t.color.bronze, t.color.amber, t.color.gold, t.color.warn, t.color.error] + +// ── Pure helpers ───────────────────────────────────────────────────── + +const fmtDur = (seconds?: number): string => { + if (!seconds || seconds <= 0) { + return '' + } + + if (seconds < 60) { + return `${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 = (order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]! + +const statusGlyph = (item: SubagentProgress, t: Theme) => { + const g = STATUS_GLYPH[item.status] + + return { color: g.color(t), glyph: g.glyph } +} + +const prepareRows = (tree: SubagentNode[], sort: SortMode, filter: FilterMode): SubagentNode[] => + tree.length === 0 + ? [] + : [...tree] + .sort(SORT_COMPARATORS[sort]) + .flatMap(n => flattenTree([n])) + .filter(FILTER_PREDICATES[filter]) + +// ── Sub-components ─────────────────────────────────────────────────── + +/** + * Detail-pane scrollbar, polled on the parent tick. `TranscriptScrollbar` + * re-renders only on scroll events — fine for the main transcript, but the + * overlay's content reflows on accordion toggle without any scroll, so the + * thumb stays stale. Ticking forces a re-read; always drawing the track + * keeps the gutter visually stable for short content too. + */ +function OverlayScrollbar({ + scrollRef, + t, + tick +}: { + scrollRef: RefObject + t: Theme + tick: number +}) { + void tick // ensures re-render when the parent clock advances + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * Math.max(1, vp - thumb)) : 0 + const below = Math.max(0, vp - thumbTop - thumb) + + const trackLines = (n: number) => (n > 0 ? `${'│\n'.repeat(Math.max(0, n - 1))}│` : '') + const thumbLines = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃` + + return ( + + {!scrollable ? ( + + {trackLines(vp)} + + ) : ( + <> + {thumbTop > 0 ? ( + + {trackLines(thumbTop)} + + ) : null} + + {thumbLines} + + {below > 0 ? ( + + {trackLines(below)} + + ) : null} + + )} + + ) +} + +/** + * Horizontal ASCII Gantt strip. One bar per subagent, anchored by row id. + * The ruler below maps screen positions to wall-clock seconds so a bar that + * "ends in the middle" reads as "finished at ~Xs". + */ +function GanttStrip({ + cols, + cursor, + flatNodes, + maxRows, + now, + t +}: { + cols: number + cursor: number + flatNodes: SubagentNode[] + maxRows: number + now: number + t: Theme +}) { + + const spans = flatNodes + .map((node, idx) => { + const started = node.item.startedAt ?? now + const ended = + node.item.durationSeconds != null && node.item.startedAt != null + ? node.item.startedAt + node.item.durationSeconds * 1000 + : now + + return { endAt: ended, idx, node, startAt: started } + }) + .filter(s => s.endAt >= s.startAt) + + if (!spans.length) { + return null + } + + const globalStart = Math.min(...spans.map(s => s.startAt)) + const globalEnd = Math.max(...spans.map(s => s.endAt)) + const totalSpan = Math.max(1, globalEnd - globalStart) + const totalSeconds = (globalEnd - globalStart) / 1000 + + // 4-col id gutter (" 12 "), rest to the bar. + const barWidth = Math.max(10, cols - 6) + 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) + + const bar = (startAt: number, endAt: number) => { + const s = Math.floor(((startAt - globalStart) / totalSpan) * barWidth) + const e = Math.min(barWidth, Math.ceil(((endAt - globalStart) / totalSpan) * barWidth)) + const fill = Math.max(1, e - s) + + 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('') + + const rulerLabels = (() => { + const chars = new Array(barWidth).fill(' ') + + for (let pos = 0; pos < barWidth; pos += 10) { + const secs = (pos / barWidth) * totalSeconds + const label = pos === 0 ? '0' : secs >= 1 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s` + + for (let j = 0; j < label.length && pos + j < barWidth; j++) { + chars[pos + j] = label[j]! + } + } + + return chars.join('') + })() + + const windowLabel = + spans.length > maxRows ? ` (${startIdx + 1}-${Math.min(spans.length, startIdx + maxRows)}/${spans.length})` : '' + + return ( + + + Timeline · {fmtDur(totalSeconds)} + {windowLabel} + + + {shown.map(({ endAt, idx, node, startAt }) => { + + const active = idx === cursor + 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' + : '' + + return ( + + + {formatRowId(idx)}{' '} + + + {bar(startAt, endAt)} + + {durLabel ? {durLabel} : null} + + ) + })} + + + {' '} + {ruler} + + + {totalSeconds >= 2 ? ( + + {' '} + {rulerLabels} + + ) : null} + + ) +} + +/** + * A collapsible section. Open-state lives on a shared atom so navigating + * between agents / list ↔ detail / history doesn't reset accordions. + */ +function OverlaySection({ + children, + count, + defaultOpen = false, + title, + t +}: { + children: ReactNode + count?: number + defaultOpen?: boolean + title: string + t: Theme +}) { + const openMap = useStore($overlaySectionsOpen) + const open = title in openMap ? openMap[title]! : defaultOpen + + return ( + + toggleOverlaySection(title, defaultOpen)}> + + {open ? '▾ ' : '▸ '} + {title} + {typeof count === 'number' ? ` (${count})` : ''} + + + + {open ? {children} : null} + + ) +} + +/** `label · value` row with the detail-pane colour hierarchy. */ +function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) { + return ( + + {name} · + {value} + + ) +} + +function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) { + const { aggregate: agg, item } = node + const { color, glyph } = statusGlyph(item, t) + + const inputTokens = item.inputTokens ?? 0 + const outputTokens = item.outputTokens ?? 0 + const localTokens = inputTokens + outputTokens + const subtreeTokens = agg.inputTokens + agg.outputTokens - localTokens + const localCost = item.costUsd ?? 0 + const subtreeCost = agg.costUsd - localCost + + const filesRead = item.filesRead ?? [] + const filesWritten = item.filesWritten ?? [] + const outputTail = item.outputTail ?? [] + // Tool calls: prefer the live stream; for archived / post-turn views + // that stream is often empty even when tool_count > 0, so fall back to + // the tool names captured in outputTail at subagent.complete time. + const toolLines = item.tools.length > 0 ? item.tools : outputTail.map(e => e.tool).filter(Boolean) + + const filesOverflow = Math.max(0, filesRead.length - 8) + Math.max(0, filesWritten.length - 8) + + return ( + + + {id ? #{id} : null} + {glyph} {item.goal} + + + + + + + {item.model ? : null} + + {item.toolsets?.length ? : null} + + + + + + {item.durationSeconds ? : null} + + {item.iteration != null ? : null} + + {item.apiCalls ? : null} + + {localTokens > 0 || localCost > 0 ? ( + + {localTokens > 0 ? ( + + {fmtTokens(inputTokens)} in · {fmtTokens(outputTokens)} out + {item.reasoningTokens ? ` · ${fmtTokens(item.reasoningTokens)} reasoning` : ''} + + } + /> + ) : null} + + {localCost > 0 ? ( + + {fmtCost(localCost)} + {subtreeCost >= 0.01 ? ` · subtree +${fmtCost(subtreeCost)}` : ''} + + } + /> + ) : null} + + {subtreeTokens > 0 ? : null} + + ) : null} + + {filesRead.length > 0 || filesWritten.length > 0 ? ( + + {filesWritten.slice(0, 8).map((p, i) => ( + + +{p} + + ))} + + {filesRead.slice(0, 8).map((p, i) => ( + + · {p} + + ))} + + {filesOverflow > 0 ? …+{filesOverflow} more : null} + + ) : null} + + {toolLines.length > 0 ? ( + + {toolLines.map((line, i) => ( + + · {line} + + ))} + + ) : null} + + {outputTail.length > 0 ? ( + + {outputTail.map((entry, i) => ( + + + {entry.tool} + {' '} + {entry.preview} + + ))} + + ) : null} + + {item.notes.length ? ( + + {item.notes.slice(-6).map((line, i) => ( + + · {line} + + ))} + + ) : null} + + {item.summary ? ( + + + {item.summary} + + + ) : null} + + ) +} + +function ListRow({ + active, + index, + node, + peak, + t, + width +}: { + active: boolean + index: number + node: SubagentNode + peak: number + t: Theme + width: number +}) { + const { color, glyph } = statusGlyph(node.item, t) + const palette = heatPalette(t) + const heatIdx = hotnessBucket(node.aggregate.hotness, peak, palette.length) + const heatMarker = heatIdx >= 2 ? palette[heatIdx]! : null + + 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) + + // 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 + + return ( + + {active ? '▸ ' : ' '} + {formatRowId(index)} + {indentFor(node.item.depth)} + {heatMarker ? : null} + {glyph} {goal} + + {tools} + {kids} + {dur ? ` · ${dur}` : ''} + + + ) +} + +function DiffPane({ + label, + snapshot, + t, + totals, + width +}: { + label: string + snapshot: SpawnSnapshot + t: Theme + totals: ReturnType + width: number +}) { + return ( + + + {label} + + + + {snapshot.label} + + + + + {formatSummary(totals)} + + + + + {snapshot.subagents + .filter(s => !s.parentId) + .slice(0, 8) + .map(s => { + const { color, glyph } = statusGlyph(s, t) + + return ( + + {glyph} {s.goal || 'subagent'} + + ) + })} + + + ) +} + +function DiffView({ + cols, + onClose, + pair, + t +}: { + cols: number + onClose: () => void + pair: { baseline: SpawnSnapshot; candidate: SpawnSnapshot } + t: Theme +}) { + const aTotals = useMemo(() => treeTotals(buildSubagentTree(pair.baseline.subagents)), [pair.baseline]) + const bTotals = useMemo(() => treeTotals(buildSubagentTree(pair.candidate.subagents)), [pair.candidate]) + const paneWidth = Math.floor((cols - 4) / 2) + + useInput((ch, key) => { + if (key.escape || ch === 'q') { + onClose() + } + }) + + const delta = (name: string, a: number, b: number, fmt: (n: number) => string): string => { + const sign = b - a === 0 ? '' : b > a ? '+' : '-' + + return `${name}: ${fmt(a)} → ${fmt(b)} (${sign}${fmt(Math.abs(b - a)) || '0'})` + } + + const round = (n: number) => String(Math.round(n)) + const sumTokens = (x: typeof aTotals) => x.inputTokens + x.outputTokens + const dollars = (n: number) => fmtCost(n) || '$0.00' + + return ( + + + + Replay diff + + baseline vs candidate · esc/q close + + + + + + + + + + + Δ + + + {delta('agents', aTotals.descendantCount, bTotals.descendantCount, round)} + {delta('tools', aTotals.totalTools, bTotals.totalTools, round)} + + {delta('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)} + + + {delta('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} + + {delta('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} + {delta('cost', aTotals.costUsd, bTotals.costUsd, dollars)} + + + ) +} + +// ── Main overlay ───────────────────────────────────────────────────── + +export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) { + const turn = useStore($turnState) + const delegation = useStore($delegationState) + const history = useStore($spawnHistory) + const diffPair = useStore($spawnDiff) + const { stdout } = useStdout() + + // historyIndex === 0: live turn. 1..N pulls the Nth-most-recent archived + // snapshot. /replay passes N on open. + const [historyIndex, setHistoryIndex] = useState(() => + Math.max(0, Math.min(history.length, Math.floor(initialHistoryIndex))) + ) + + const [sort, setSort] = useState('depth-first') + const [filter, setFilter] = useState('all') + const [cursor, setCursor] = useState(0) + const [flash, setFlash] = useState('') + const [now, setNow] = useState(() => Date.now()) + // cc-style view switching: list = full-width row picker, detail = full-width + // scrollable pane. Two panes side-by-side in Ink fought Yoga flex. + const [mode, setMode] = useState<'detail' | 'list'>('list') + + const detailScrollRef = useRef(null) + const prevLiveCountRef = useRef(turn.subagents.length) + + // ── Derived state ────────────────────────────────────────────────── + + const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null + // Instant fallback to history[0] the moment the live list clears — avoids + // a one-frame "no subagents" flash while the auto-follow effect fires. + const justFinishedSnapshot = historyIndex === 0 && turn.subagents.length === 0 ? (history[0] ?? null) : null + const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot + const replayMode = effectiveSnapshot != null + const subagents = replayMode ? effectiveSnapshot.subagents : turn.subagents + + const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const totals = useMemo(() => treeTotals(tree), [tree]) + const widths = useMemo(() => widthByDepth(tree), [tree]) + const spark = useMemo(() => sparkline(widths), [widths]) + const peak = useMemo(() => peakHotness(tree), [tree]) + const rows = useMemo(() => prepareRows(tree, sort, filter), [tree, sort, filter]) + + const selected = rows[cursor] ?? null + + const cols = stdout?.columns ?? 80 + const rowsH = Math.max(8, (stdout?.rows ?? 24) - 10) + const listWindowStart = Math.max(0, cursor - Math.floor(rowsH / 2)) + + // ── Effects ──────────────────────────────────────────────────────── + + useEffect(() => { + // Ticker drives both the live gantt and OverlayScrollbar content-reflow + // detection. Slower in replay (nothing's growing) but not stopped + // because accordions still expand. + const id = setInterval(() => setNow(Date.now()), replayMode ? 300 : 500) + + return () => clearInterval(id) + }, [replayMode]) + + useEffect(() => { + // Clamp stale index when history grows/shrinks beneath us. + if (historyIndex > history.length) { + setHistoryIndex(history.length) + } + }, [history.length, historyIndex]) + + useEffect(() => { + // Auto-follow the just-finished turn onto history[1] so the user isn't + // dropped into an empty live view. Fires only when transitioning from + // "had live subagents" → "live empty" while in live mode. + const prev = prevLiveCountRef.current + prevLiveCountRef.current = turn.subagents.length + + if (historyIndex === 0 && prev > 0 && turn.subagents.length === 0 && history.length > 0) { + setHistoryIndex(1) + setCursor(0) + setFlash('turn finished · inspect freely · q to close') + } + }, [history.length, historyIndex, turn.subagents.length]) + + useEffect(() => { + // Reset detail scroll on navigation so the top of the new node shows. + detailScrollRef.current?.scrollTo(0) + }, [cursor, historyIndex, mode]) + + useEffect(() => { + // Warm caps + paused flag on open. + gw.request('delegation.status', {}) + .then(r => applyDelegationStatus(asRpcResult(r))) + .catch(() => {}) + }, [gw]) + + useEffect(() => { + if (cursor >= rows.length) { + setCursor(Math.max(0, rows.length - 1)) + } + }, [cursor, rows.length]) + + // ── Actions ──────────────────────────────────────────────────────── + + const guardLive = (action: () => void) => { + if (replayMode) { + setFlash('replay mode — controls disabled') + } else { + action() + } + } + + const interrupt = (id: string) => gw.request('subagent.interrupt', { subagent_id: id }) + + const killOne = (id: string) => + guardLive(() => { + interrupt(id) + .then(raw => { + const r = asRpcResult(raw) + setFlash(r?.found ? `killing ${id}` : `not found: ${id}`) + }) + .catch(() => setFlash(`kill failed: ${id}`)) + }) + + const killSubtree = (node: SubagentNode) => + guardLive(() => { + const ids = [node.item.id, ...descendantIds(node)] + ids.forEach(id => interrupt(id).catch(() => {})) + setFlash(`killing subtree · ${ids.length} node${ids.length === 1 ? '' : 's'}`) + }) + + const togglePause = () => + guardLive(() => { + gw.request('delegation.pause', { paused: !delegation.paused }) + .then(raw => { + const r = asRpcResult(raw) + applyDelegationStatus({ paused: r?.paused }) + setFlash(r?.paused ? 'spawning paused' : 'spawning resumed') + }) + .catch(() => setFlash('pause failed')) + }) + + const stepHistory = (delta: -1 | 1) => + setHistoryIndex(idx => { + const next = Math.max(0, Math.min(history.length, idx + delta)) + + if (next !== idx) { + setCursor(0) + setFlash(next === 0 ? 'live turn' : `replay · ${next}/${history.length}`) + } + + return next + }) + + const closeWithCleanup = () => { + clearDiffPair() + onClose() + } + + // ── Input ────────────────────────────────────────────────────────── + + const detailPageSize = Math.max(4, rowsH - 2) + const scrollDetail = (dy: number) => detailScrollRef.current?.scrollBy(dy) + + useInput((ch, key) => { + if (ch === 'q') { + return closeWithCleanup() + } + + if (key.escape) { + return mode === 'detail' ? setMode('list') : closeWithCleanup() + } + + // Shared actions (both modes). + if (ch === '<' || ch === '[') { + return stepHistory(1) + } + i +f (ch === '>' || ch === ']') { + return stepHistory(-1) + } + if (ch === 'p') { + + return togglePause() + } + i +f (ch === 'x' && selected) { + return killOne(selected.item.id) + } + if (ch === 'X' && selected) { + + return killSubtree(selected) + } + + if (mode === 'detail') { + if (key.leftArrow || ch === 'h') { + return setMode('list') + } + i +f (key.pageUp || (key.ctrl && ch === 'u')) { + return scrollDetail(-detailPageSize) + } + if (key.pageDown || (key.ctrl && ch === 'd')) { + + return scrollDetail(detailPageSize) + } + i +f (key.upArrow || ch === 'k') { + return scrollDetail(-2) + } + if (key.downArrow || ch === 'j') { + + return scrollDetail(2) + } + i +f (ch === 'g') { + return detailScrollRef.current?.scrollTo(0) + } + if (ch === 'G') { + + return detailScrollRef.current?.scrollToBottom?.() + } + + return + } + + // List mode. + if ((key.return || key.rightArrow || ch === 'l') && selected) { + return setMode('detail') + } + i +f (key.upArrow || ch === 'k') { + return setCursor(c => Math.max(0, c - 1)) + } + if (key.downArrow || ch === 'j') { + + return setCursor(c => Math.min(Math.max(0, rows.length - 1), c + 1)) + } + i +f (ch === 'g') { + return setCursor(0) + } + if (ch === 'G') { + + return setCursor(Math.max(0, rows.length - 1)) + } + i +f (ch === 's') { + return setSort(m => cycle(SORT_ORDER, m)) + } + i +f (ch === 'f') { + return setFilter(m => cycle(FILTER_ORDER, m)) + } + }) + + // ── Header assembly ──────────────────────────────────────────────── + + const mix = Object.entries( + subagents.reduce>((acc, it) => { + const key = it.model ? it.model.split('/').pop()! : 'inherit' + acc[key] = (acc[key] ?? 0) + 1 + + return acc + }, {}) + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([k, v]) => `${k}×${v}`) + .join(' · ') + + const capsLabel = delegation.maxSpawnDepth + ? `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. + 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' + + return `${position} · finished ${at}` + })() + + const controlsHint = replayMode + ? ' · controls locked' + : ` · x kill · X subtree · p ${delegation.paused ? 'resume' : 'pause'}` + + // ── Rendering ────────────────────────────────────────────────────── + + if (diffPair) { + return + } + + return ( + + + + {title} + + + + {formatSummary(totals)} + {spark ? ` ${spark}` : ''} + {capsLabel ? ` ${capsLabel}` : ''} + {mix ? ` · ${mix}` : ''} + + + + {rows.length === 0 ? ( + + No subagents this turn. Trigger delegate_task to populate the tree. + + ) : mode === 'list' ? ( + + + + + {rows.slice(listWindowStart, listWindowStart + rowsH).map((node, i) => ( + + ))} + + + ) : ( + + + + {selected ? : null} + + + + + + + + )} + + + {flash ? {flash} : null} + + {mode === 'list' ? ( + + ↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter: + {FILTER_LABEL[filter]} + {history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''} + {' · q close'} + + ) : ( + + ↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close + + )} + + + ) +} + +interface AgentsOverlayProps { + gw: GatewayClient + initialHistoryIndex?: number + onClose: () => void + t: Theme +} + +export const closeAgentsOverlay = () => patchOverlayState({ agents: false }) +export const openAgentsOverlay = () => patchOverlayState({ agents: true }) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 28f7b324e..1e46272de 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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 ( + + {atCap ? ' │ ⚠ ' : ' │ '} + {pieces.join(' ')} + + ) +} + function SessionDuration({ startedAt }: { startedAt: number }) { const [now, setNow] = useState(() => Date.now()) @@ -145,6 +201,7 @@ export function StatusRule({ ) : null} + {voiceLabel ? │ {voiceLabel} : null} {bgCount > 0 ? │ {bgCount} bg : null} {showCost && typeof usage.cost_usd === 'number' ? ( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ad854033a..959b6ea70 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -2,13 +2,15 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { memo } from 'react' +import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js' -import { $isBlocked } from '../app/overlayStore.js' +import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' import type { Theme } from '../theme.js' import type { DetailsMode } from '../types.js' +import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { FloatingOverlays, PromptZone } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' @@ -256,6 +258,21 @@ const ComposerPane = memo(function ComposerPane({ ) }) +const AgentsOverlayPane = memo(function AgentsOverlayPane() { + const { gw } = useGateway() + const ui = useStore($uiState) + const overlay = useStore($overlayState) + + return ( + patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })} + t={ui.theme} + /> + ) +}) + export const AppLayout = memo(function AppLayout({ actions, composer, @@ -264,22 +281,30 @@ export const AppLayout = memo(function AppLayout({ status, transcript }: AppLayoutProps) { + const overlay = useStore($overlayState) + return ( - + {overlay.agents ? ( + + ) : ( + + )} - + {!overlay.agents && ( + + )} - + {!overlay.agents && } ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 37c9598f8..a59cdc41d 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,8 +1,19 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' +import { + buildSubagentTree, + fmtCost, + fmtTokens, + formatSummary as formatSpawnSummary, + hotnessBucket, + peakHotness, + sparkline, + treeTotals, + widthByDepth +} from '../lib/subagentTree.js' import { compactPreview, estimateTokensRough, @@ -14,7 +25,7 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js' +import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] @@ -106,6 +117,8 @@ function TreeNode({ header, open, rails = [], + stemColor, + stemDim, t }: { branch: TreeBranch @@ -113,11 +126,13 @@ function TreeNode({ header: ReactNode open: boolean rails?: TreeRails + stemColor?: string + stemDim?: boolean t: Theme }) { return ( - + {header} {open ? children?.(nextTreeRails(rails, branch)) : null} @@ -239,16 +254,31 @@ function Chevron({ ) } +function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined { + const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error] + const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length) + + // Below the median bucket we keep the default dim stem so cool branches + // fade into the chrome — only "hot" branches draw the eye. + if (idx < 2) { + return undefined + } + + return palette[idx] +} + function SubagentAccordion({ branch, expanded, - item, + node, + peak, rails = [], t }: { branch: TreeBranch expanded: boolean - item: SubagentProgress + node: SubagentNode + peak: number rails?: TreeRails t: Theme }) { @@ -257,6 +287,7 @@ function SubagentAccordion({ const [openThinking, setOpenThinking] = useState(expanded) const [openTools, setOpenTools] = useState(expanded) const [openNotes, setOpenNotes] = useState(expanded) + const [openKids, setOpenKids] = useState(expanded) useEffect(() => { if (!expanded) { @@ -268,6 +299,7 @@ function SubagentAccordion({ setOpenThinking(true) setOpenTools(true) setOpenNotes(true) + setOpenKids(true) }, [expanded]) const expandAll = () => { @@ -276,8 +308,13 @@ function SubagentAccordion({ setOpenThinking(true) setOpenTools(true) setOpenNotes(true) + setOpenKids(true) } + const item = node.item + const children = node.children + const aggregate = node.aggregate + const statusTone: 'dim' | 'error' | 'warn' = item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' @@ -286,10 +323,60 @@ function SubagentAccordion({ const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) - const suffix = - item.status === 'running' - ? 'running' - : `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}` + // Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost. + // Emphasises the numbers the user can't easily eyeball from a flat list. + const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status) + + const rollupBits: string[] = [statusLabel] + + if (item.durationSeconds) { + rollupBits.push(fmtElapsed(item.durationSeconds * 1000)) + } + + const localTools = item.toolCount ?? 0 + const subtreeTools = aggregate.totalTools - localTools + + if (localTools > 0) { + rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`) + } + + const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0) + + if (localTokens > 0) { + rollupBits.push(`${fmtTokens(localTokens)} tok`) + } + + const localCost = item.costUsd ?? 0 + + if (localCost > 0) { + rollupBits.push(fmtCost(localCost)) + } + + const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0) + + if (filesLocal > 0) { + rollupBits.push(`⎘${filesLocal}`) + } + + if (children.length > 0) { + rollupBits.push(`${aggregate.descendantCount}↓`) + + if (subtreeTools > 0) { + rollupBits.push(`+${subtreeTools}t sub`) + } + + const subCost = aggregate.costUsd - localCost + + if (subCost >= 0.01) { + rollupBits.push(`+${fmtCost(subCost)} sub`) + } + + if (aggregate.activeCount > 0 && item.status !== 'running') { + rollupBits.push(`⚡${aggregate.activeCount}`) + } + } + + const suffix = rollupBits.join(' · ') const thinkingText = item.thinking.join('\n') const hasThinking = Boolean(thinkingText) @@ -418,6 +505,50 @@ function SubagentAccordion({ }) } + if (children.length > 0) { + // Nested grandchildren — rendered recursively via SubagentAccordion, + // sharing the same keybindings / expand semantics as top-level nodes. + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenKids(v => !v) + } + }} + open={showChildren || openKids} + suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`} + t={t} + title="Spawned" + /> + ), + key: 'subagents', + open: showChildren || openKids, + render: childRails => ( + + {children.map((child, i) => ( + + ))} + + ) + }) + } + + // Heatmap: amber→error gradient on the stem when this branch is "hot" + // (high tools/sec) relative to the whole tree's peak. + const stem = heatColor(node, peak, t) + return ( {childRails => ( @@ -598,6 +731,16 @@ export const ToolTrail = memo(function ToolTrail({ const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) + // Spawn-tree derivations must live above any early return so React's + // rules-of-hooks sees a stable call order. Cheap O(N) builds memoised + // by subagent-list identity. + const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree]) + const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree]) + const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree]) + const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths]) + const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals]) + if ( !busy && !trail.length && @@ -753,12 +896,13 @@ export const ToolTrail = memo(function ToolTrail({ const renderSubagentList = (rails: boolean[]) => ( - {subagents.map((item, index) => ( + {spawnTree.map((node, index) => ( @@ -881,10 +1025,14 @@ export const ToolTrail = memo(function ToolTrail({ } if (hasSubagents && !inlineDelegateKey) { + // Spark + summary give a one-line read on the branch shape before + // opening the subtree. `/agents` opens the full-screen audit overlay. + const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` + sections.push({ header: ( { if (shift) { expandAll() @@ -895,8 +1043,9 @@ export const ToolTrail = memo(function ToolTrail({ } }} open={detailsMode === 'expanded' || openSubagents} + suffix={suffix} t={t} - title="Subagents" + title="Spawn tree" /> ), key: 'subagents', diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6fa1ad92e..975ec117e 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -280,15 +280,85 @@ export interface ReloadMcpResponse { // ── Subagent events ────────────────────────────────────────────────── export interface SubagentEventPayload { + api_calls?: number + cost_usd?: number + depth?: number duration_seconds?: number + files_read?: string[] + files_written?: string[] goal: string - status?: 'completed' | 'failed' | 'interrupted' | 'running' + input_tokens?: number + iteration?: number + model?: string + output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] + output_tokens?: number + parent_id?: null | string + reasoning_tokens?: number + status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' + subagent_id?: string summary?: string task_count?: number task_index: number text?: string + tool_count?: number tool_name?: string tool_preview?: string + toolsets?: string[] +} + +// ── Delegation control RPCs ────────────────────────────────────────── + +export interface DelegationStatusResponse { + active?: { + depth?: number + goal?: string + model?: null | string + parent_id?: null | string + started_at?: number + status?: string + subagent_id?: string + tool_count?: number + }[] + max_concurrent_children?: number + max_spawn_depth?: number + paused?: boolean +} + +export interface DelegationPauseResponse { + paused?: boolean +} + +export interface SubagentInterruptResponse { + found?: boolean + subagent_id?: string +} + +// ── Spawn-tree snapshots ───────────────────────────────────────────── + +export interface SpawnTreeListEntry { + count: number + finished_at?: number + label?: string + path: string + session_id?: string + started_at?: number | null +} + +export interface SpawnTreeListResponse { + entries?: SpawnTreeListEntry[] +} + +export interface SpawnTreeLoadResponse { + finished_at?: number + label?: string + session_id?: string + started_at?: null | number + subagents?: unknown[] +} + +export interface SpawnTreeSaveResponse { + path?: string + session_id?: string } export type GatewayEvent = @@ -320,6 +390,7 @@ export type GatewayEvent = | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload: { text: string }; session_id?: string; type: 'btw.complete' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' } diff --git a/ui-tui/src/lib/subagentTree.ts b/ui-tui/src/lib/subagentTree.ts new file mode 100644 index 000000000..cad6005b7 --- /dev/null +++ b/ui-tui/src/lib/subagentTree.ts @@ -0,0 +1,339 @@ +import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js' + +const ROOT_KEY = '__root__' + +/** + * Reconstruct the subagent spawn tree from a flat event-ordered list. + * + * Grouping is by `parentId`; a missing `parentId` (or one pointing at an + * unknown subagent) is treated as a top-level spawn of the current turn. + * Children within a parent are sorted by `depth` then `index` — same key + * used in `turnController.upsertSubagent`, so render order matches spawn + * order regardless of network reordering of gateway events. + * + * Older gateways omit `parentId`; every subagent is then a top-level node + * and the tree renders flat — matching pre-observability behaviour. + */ +export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] { + if (!items.length) { + return [] + } + + const byParent = new Map() + const known = new Set() + + for (const item of items) { + known.add(item.id) + } + + for (const item of items) { + const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY + const bucket = byParent.get(parentKey) ?? [] + bucket.push(item) + byParent.set(parentKey, bucket) + } + + for (const bucket of byParent.values()) { + bucket.sort((a, b) => a.depth - b.depth || a.index - b.index) + } + + const build = (item: SubagentProgress): SubagentNode => { + const kids = byParent.get(item.id) ?? [] + const children = kids.map(build) + + return { aggregate: aggregate(item, children), children, item } + } + + return (byParent.get(ROOT_KEY) ?? []).map(build) +} + +/** + * Roll up counts for a node's whole subtree. Kept pure so the live view + * and the post-hoc replay can share the same renderer unchanged. + * + * `hotness` = tools per second across the subtree — a crude proxy for + * "how much work is happening in this branch". Used to colour tree rails + * in the overlay / inline view so the eye spots the expensive branch. + */ +export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate { + let totalTools = item.toolCount ?? 0 + let totalDuration = item.durationSeconds ?? 0 + let descendantCount = 0 + let activeCount = isRunning(item) ? 1 : 0 + let maxDepthFromHere = 0 + let inputTokens = item.inputTokens ?? 0 + let outputTokens = item.outputTokens ?? 0 + let costUsd = item.costUsd ?? 0 + let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0) + + for (const child of children) { + totalTools += child.aggregate.totalTools + totalDuration += child.aggregate.totalDuration + descendantCount += child.aggregate.descendantCount + 1 + activeCount += child.aggregate.activeCount + maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1) + inputTokens += child.aggregate.inputTokens + outputTokens += child.aggregate.outputTokens + costUsd += child.aggregate.costUsd + filesTouched += child.aggregate.filesTouched + } + + const hotness = totalDuration > 0 ? totalTools / totalDuration : 0 + + return { + activeCount, + costUsd, + descendantCount, + filesTouched, + hotness, + inputTokens, + maxDepthFromHere, + outputTokens, + totalDuration, + totalTools + } +} + +/** + * Count of subagents at each depth level, indexed by depth (0 = top level). + * Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD. + */ +export function widthByDepth(tree: readonly SubagentNode[]): number[] { + const widths: number[] = [] + + const walk = (nodes: readonly SubagentNode[], depth: number) => { + if (!nodes.length) { + return + } + + widths[depth] = (widths[depth] ?? 0) + nodes.length + + for (const node of nodes) { + walk(node.children, depth + 1) + } + } + + walk(tree, 0) + + return widths +} + +/** + * Flat totals across the full tree — feeds the summary chip header. + */ +export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate { + let totalTools = 0 + let totalDuration = 0 + let descendantCount = 0 + let activeCount = 0 + let maxDepthFromHere = 0 + let inputTokens = 0 + let outputTokens = 0 + let costUsd = 0 + let filesTouched = 0 + + for (const node of tree) { + totalTools += node.aggregate.totalTools + totalDuration += node.aggregate.totalDuration + descendantCount += node.aggregate.descendantCount + 1 + activeCount += node.aggregate.activeCount + maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1) + inputTokens += node.aggregate.inputTokens + outputTokens += node.aggregate.outputTokens + costUsd += node.aggregate.costUsd + filesTouched += node.aggregate.filesTouched + } + + const hotness = totalDuration > 0 ? totalTools / totalDuration : 0 + + return { + activeCount, + costUsd, + descendantCount, + filesTouched, + hotness, + inputTokens, + maxDepthFromHere, + outputTokens, + totalDuration, + totalTools + } +} + +/** + * Flatten the tree into visit order — useful for keyboard navigation and + * for "kill subtree" walks that fire one RPC per descendant. + */ +export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] { + const out: SubagentNode[] = [] + + const walk = (nodes: readonly SubagentNode[]) => { + for (const node of nodes) { + out.push(node) + walk(node.children) + } + } + + walk(tree) + + return out +} + +/** + * Collect every descendant's id for a given node (excluding the node itself). + */ +export function descendantIds(node: SubagentNode): string[] { + const ids: string[] = [] + + const walk = (children: readonly SubagentNode[]) => { + for (const child of children) { + ids.push(child.item.id) + walk(child.children) + } + } + + walk(node.children) + + return ids +} + +export function isRunning(item: Pick): boolean { + return item.status === 'running' || item.status === 'queued' +} + +const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const + +/** + * 8-step unicode bar sparkline from a positive-integer array. Zeroes render + * as spaces so a sparse tree doesn't read as equal activity at every depth. + */ +export function sparkline(values: readonly number[]): string { + if (!values.length) { + return '' + } + + const max = Math.max(...values) + + if (max <= 0) { + return ' '.repeat(values.length) + } + + return values + .map(v => { + if (v <= 0) { + return ' ' + } + + const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1)))) + + return SPARK_RAMP[idx] + }) + .join('') +} + +/** + * Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s` + */ +export function formatSummary(totals: SubagentAggregate): string { + const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`] + pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`) + + if (totals.totalTools > 0) { + pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`) + } + + if (totals.totalDuration > 0) { + pieces.push(fmtDuration(totals.totalDuration)) + } + + const tokens = totals.inputTokens + totals.outputTokens + + if (tokens > 0) { + pieces.push(`${fmtTokens(tokens)} tok`) + } + + if (totals.costUsd > 0) { + pieces.push(fmtCost(totals.costUsd)) + } + + if (totals.activeCount > 0) { + pieces.push(`⚡${totals.activeCount}`) + } + + return pieces.join(' · ') +} + +/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */ +export function fmtCost(usd: number): string { + if (!Number.isFinite(usd) || usd <= 0) { + return '' + } + + if (usd < 0.01) { + return '<$0.01' + } + + if (usd < 10) { + return `$${usd.toFixed(2)}` + } + + return `$${usd.toFixed(1)}` +} + +/** Compact token count: `12k`, `1.2k`, `542`. */ +export function fmtTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) { + return '0' + } + + if (n < 1000) { + return String(Math.round(n)) + } + + if (n < 10_000) { + return `${(n / 1000).toFixed(1)}k` + } + + return `${Math.round(n / 1000)}k` +} + +function fmtDuration(seconds: number): string { + if (seconds < 60) { + return `${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` +} + +/** + * Normalize a node's hotness into a palette index 0..N-1 where N = buckets. + * Higher hotness = "hotter" colour. Normalized against the tree's peak hotness + * so a uniformly slow tree still shows gradient across its busiest branches. + */ +export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number { + if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) { + return 0 + } + + const ratio = Math.min(1, hotness / peakHotness) + + return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1)))) +} + +export function peakHotness(tree: readonly SubagentNode[]): number { + let peak = 0 + + const walk = (nodes: readonly SubagentNode[]) => { + for (const node of nodes) { + peak = Math.max(peak, node.aggregate.hotness) + walk(node.children) + } + } + + walk(tree) + + return peak +} diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 122907895..daeedb337 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -94,7 +94,12 @@ export const DARK_THEME: Theme = { amber: '#FFBF00', bronze: '#CD7F32', cornsilk: '#FFF8DC', - dim: '#B8860B', + // Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which + // read as barely-visible on dark terminals for long body text. The + // new value sits ~60% luminance — readable without losing the "muted / + // secondary" semantic. Field labels still use `label` (65%) which + // stays brighter so hierarchy holds. + dim: '#CC9B1F', completionBg: '#FFFFFF', completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), @@ -104,8 +109,11 @@ export const DARK_THEME: Theme = { warn: '#ffa726', prompt: '#FFF8DC', - sessionLabel: '#B8860B', - sessionBorder: '#B8860B', + // sessionLabel/sessionBorder intentionally track the `dim` value — they + // are "same role, same colour" by design. fromSkin's banner_dim fallback + // relies on this pairing (#11300). + sessionLabel: '#CC9B1F', + sessionBorder: '#CC9B1F', statusBg: '#1a1a2e', statusFg: '#C0C0C0', diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 3045a74a8..63d6c6d4f 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -12,16 +12,72 @@ export interface ActivityItem { } export interface SubagentProgress { + apiCalls?: number + costUsd?: number + depth: number durationSeconds?: number + filesRead?: string[] + filesWritten?: string[] goal: string id: string index: number + inputTokens?: number + iteration?: number + model?: string notes: string[] - status: 'completed' | 'failed' | 'interrupted' | 'running' + outputTail?: SubagentOutputEntry[] + outputTokens?: number + parentId: null | string + reasoningTokens?: number + startedAt?: number + status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' summary?: string taskCount: number thinking: string[] + toolCount: number tools: string[] + toolsets?: string[] +} + +export interface SubagentOutputEntry { + isError: boolean + preview: string + tool: string +} + +export interface SubagentNode { + aggregate: SubagentAggregate + children: SubagentNode[] + item: SubagentProgress +} + +export interface SubagentAggregate { + activeCount: number + costUsd: number + descendantCount: number + filesTouched: number + hotness: number + inputTokens: number + maxDepthFromHere: number + outputTokens: number + totalDuration: number + totalTools: number +} + +export interface DelegationStatus { + active: { + depth?: number + goal?: string + model?: null | string + parent_id?: null | string + started_at?: number + status?: string + subagent_id?: string + tool_count?: number + }[] + max_concurrent_children?: number + max_spawn_depth?: number + paused: boolean } export interface ApprovalReq {