From 70d28b62fbc9c47e2e3659ad1222ed2ecfe0e89c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:09:08 -0700 Subject: [PATCH] feat(cli): track background subagents in the status bar (#51441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic prompt_toolkit status bar already shows two background indicators: ▶ N (/background agent threads) and ⚙ N (shell processes spawned by terminal(background=true)). Background/async subagents (delegate_task batches and background single delegations) had no indicator despite being long-running work the user should be able to see at a glance. Add a third indicator ⛓ N sourced from tools.async_delegation.active_count() — the count of delegations still in the 'running' state. Renders in the plain-text builder and the styled-fragment builder across the same width tiers as the other two (omitted on the narrow <52 tier), guarded so a raising active_count() leaves the snapshot at 0. --- cli.py | 25 ++++++ .../test_cli_background_status_indicator.py | 79 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/cli.py b/cli.py index 39498e696d4..0d6f52ac5ab 100644 --- a/cli.py +++ b/cli.py @@ -4222,6 +4222,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): "compressions": 0, "active_background_tasks": 0, "active_background_processes": 0, + "active_background_subagents": 0, } # Count live /background tasks. The dict entry is removed in the @@ -4242,6 +4243,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except Exception: pass + # Count live background/async subagents (delegate_task batches and + # background single delegations tracked by tools.async_delegation). + # active_count() iterates an in-memory records dict under a lock — + # cheap and only counts records still in the "running" state. + try: + from tools.async_delegation import active_count as _async_active_count + snapshot["active_background_subagents"] = _async_active_count() + except Exception: + pass + if not agent: return snapshot @@ -4493,6 +4504,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): bg_proc_count = snapshot.get("active_background_processes", 0) if bg_proc_count: parts.append(f"⚙ {bg_proc_count}") + bg_subagent_count = snapshot.get("active_background_subagents", 0) + if bg_subagent_count: + parts.append(f"⛓ {bg_subagent_count}") parts.append(duration_label) if yolo_active: parts.append("⚠ YOLO") @@ -4515,6 +4529,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): bg_proc_count = snapshot.get("active_background_processes", 0) if bg_proc_count: parts.append(f"⚙ {bg_proc_count}") + bg_subagent_count = snapshot.get("active_background_subagents", 0) + if bg_subagent_count: + parts.append(f"⛓ {bg_subagent_count}") parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -4560,6 +4577,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): compressions = snapshot.get("compressions", 0) bg_count = snapshot.get("active_background_tasks", 0) bg_proc_count = snapshot.get("active_background_processes", 0) + bg_subagent_count = snapshot.get("active_background_subagents", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -4575,6 +4593,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if bg_proc_count: frags.append(("class:status-bar-dim", " · ")) frags.append(("class:status-bar-strong", f"⚙ {bg_proc_count}")) + if bg_subagent_count: + frags.append(("class:status-bar-dim", " · ")) + frags.append(("class:status-bar-strong", f"⛓ {bg_subagent_count}")) frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), @@ -4595,6 +4616,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): compressions = snapshot.get("compressions", 0) bg_count = snapshot.get("active_background_tasks", 0) bg_proc_count = snapshot.get("active_background_processes", 0) + bg_subagent_count = snapshot.get("active_background_subagents", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -4614,6 +4636,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if bg_proc_count: frags.append(("class:status-bar-dim", " │ ")) frags.append(("class:status-bar-strong", f"⚙ {bg_proc_count}")) + if bg_subagent_count: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-strong", f"⛓ {bg_subagent_count}")) frags.extend([ ("class:status-bar-dim", " │ "), ("class:status-bar-dim", duration_label), diff --git a/tests/cli/test_cli_background_status_indicator.py b/tests/cli/test_cli_background_status_indicator.py index 047dca77cb3..ed5716f2389 100644 --- a/tests/cli/test_cli_background_status_indicator.py +++ b/tests/cli/test_cli_background_status_indicator.py @@ -189,3 +189,82 @@ def test_indicators_independent_agents_and_processes(monkeypatch): rendered = "".join(text for _style, text in frags) assert "▶ 1" in rendered assert "⚙ 2" in rendered + + +# ── Background/async subagent indicator (⛓ N) ───────────────────────────── +# Source of truth is tools.async_delegation.active_count() — the count of +# delegate_task delegations (batch + background single) still in the +# "running" state. Distinct from ▶ (/background agent threads) and ⚙ (shell +# processes); all three can be active at once. + + +def _patch_async_active(monkeypatch, count: int) -> None: + import tools.async_delegation as ad_mod + monkeypatch.setattr(ad_mod, "active_count", lambda: count) + + +def test_snapshot_reports_zero_when_no_background_subagents(monkeypatch): + cli_obj = _make_cli() + _patch_async_active(monkeypatch, 0) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_subagents"] == 0 + + +def test_snapshot_counts_live_background_subagents(monkeypatch): + cli_obj = _make_cli() + _patch_async_active(monkeypatch, 4) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_subagents"] == 4 + + +def test_snapshot_safe_when_async_active_count_raises(monkeypatch): + """If active_count() raises the snapshot stays at 0; no propagate.""" + cli_obj = _make_cli() + import tools.async_delegation as ad_mod + + def _boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(ad_mod, "active_count", _boom) + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_subagents"] == 0 + + +def test_plain_text_status_shows_subagent_indicator_when_active(monkeypatch): + cli_obj = _make_cli() + _patch_async_active(monkeypatch, 3) + text = cli_obj._build_status_bar_text(width=80) + assert "⛓ 3" in text + + +def test_plain_text_status_omits_subagent_indicator_when_idle(monkeypatch): + cli_obj = _make_cli() + _patch_async_active(monkeypatch, 0) + text = cli_obj._build_status_bar_text(width=80) + assert "⛓" not in text + + +def test_fragments_include_subagent_segment_when_active(monkeypatch): + cli_obj = _make_cli() + _patch_async_active(monkeypatch, 2) + cli_obj._status_bar_visible = True + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "⛓ 2" in rendered + + +def test_all_three_background_indicators_independent(monkeypatch): + """▶ (agent tasks), ⚙ (shell processes), ⛓ (subagents) all coexist.""" + cli_obj = _make_cli() + cli_obj._background_tasks = {"bg_a": _stub_thread()} + _patch_process_registry(monkeypatch, 2) + _patch_async_active(monkeypatch, 5) + cli_obj._status_bar_visible = True + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "▶ 1" in rendered + assert "⚙ 2" in rendered + assert "⛓ 5" in rendered +