Files
hermes-agent/hermes_cli/cli_subagent_monitor.py
teknium1 43455f5670 feat(cli): live dock shows the standing /goal and queued prompts
The classic CLI dock above the status bar painted subagents and background
processes only. A standing /goal (and whether it is active, parked or
paused) was visible only as a "⊙ goal N/M" status-bar segment, and prompts
waiting in /queue could not be seen at all while the agent worked.

The dock now paints the goal's status line on its top row and a Queue block
(count, first three previews, +N more) on its bottom rows, closest to the
input they will be sent from; F7's summary line carries "goal active|parked|
paused" and "N queued". Goal state comes only from the GoalManager the CLI
already holds for the current session, so the 1 Hz dock refresh never opens
state.db and never shows a previous session's goal after /new.

/queue now dispatches inline while the agent runs, honouring its
busy_policy="dispatch": typed mid-run it was queued as the raw command, so
"/queue X" only re-enqueued X after the turn and /queue list|rm|edit could
not inspect the queue until it had drained.
2026-09-23 10:05:40 -07:00

456 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Classic CLI live-work dock (subagents, background processes, active goal, queued prompts) and
scoped controls; no agent-loop state is changed. Process rows come from ``cli_process_dock``,
goal/queue rows from ``cli_session_dock``."""
from __future__ import annotations
import json
import time
from prompt_toolkit.utils import get_cwidth
from hermes_cli import cli_process_dock as procs
from hermes_cli import cli_session_dock as session_rows
def _clip(value, width):
text = ' '.join(str(value or '').split())
text = ''.join(c for c in text if c.isprintable())
if get_cwidth(text) <= width:
return text
result = ''
for char in text:
if get_cwidth(result + char) > max(0, width - 1):
break
result += char
return result + ('…' if width else '')
class SubagentMonitor:
def __init__(self, cli):
self.cli = cli
self.entries = []
self.processes = []
self.goal = ''
self.queued = []
self.selected_id = None
self._signature = None
self._last_poll = 0
self.app = None
self.opening = False
self.collapsed = False
@property
def has_rows(self):
return bool(self.entries or self.processes or self.goal or self.queued)
@property
def roster(self):
"""Agents first, then processes — the order the dock and the full-height monitor paint."""
return [*self.entries, *self.processes]
@staticmethod
def _key(row):
return row.get('key', row.get('subagent_id'))
@property
def selected(self):
return next((r for r in self.roster if self._key(r) == self.selected_id), None)
@property
def selected_process(self):
row = self.selected
return row if row and row.get('kind') == 'process' else None
def refresh(self, now=None):
from tools.delegate_tool_registry import _list_payload, list_active_subagents
now = time.time() if now is None else now
parent = getattr(self.cli, 'agent', None)
entries = _list_payload(parent)['subagents'] if parent is not None else []
# The scoped control-plane snapshot supplies authority and transcript paths;
# its matching public lifecycle record supplies the latest observed tool.
activity = {r['subagent_id']: r for r in list_active_subagents()} if entries else {}
for row in entries:
live = activity.get(row['subagent_id'], {})
row['elapsed'] = max(0, int(now - live.get('started_at', now)))
row['last_tool'] = live.get('last_tool') or ''
row['key'] = row['subagent_id']
row.pop('running_seconds', None)
processes = procs.process_rows(now)
goal = session_rows.goal_line(self.cli)
queued = session_rows.queued_prompts(self.cli)
signature = json.dumps([entries, processes, goal, queued], sort_keys=True, default=str)
changed = signature != self._signature
self._signature = signature
self.entries = entries
self.processes = processes
self.goal = goal
self.queued = queued
if self.selected is None:
roster = self.roster
self.selected_id = self._key(roster[0]) if roster else None
return changed
def invalidate(self):
from hermes_cli.cli_terminal_mixin import _run_on_app_loop
app = self.app
if app is not None:
# Teardown clears app.loop; don't let it interleave with a worker's
# invalidate call, which reads the loop more than once.
_run_on_app_loop(app, app.invalidate)
def tick(self):
now = time.monotonic()
if now - self._last_poll < 1:
return
self._last_poll = now
if self.refresh():
if self.app is not None:
self.invalidate()
else:
self.cli._invalidate()
def select(self, delta):
roster = self.roster
if roster:
index = next((i for i, r in enumerate(roster) if self._key(r) == self.selected_id), 0)
self.selected_id = self._key(roster[(index + delta) % len(roster)])
def control(self, action, message=None, *, target=None):
target = target or self.selected_id
if any(r['key'] == target for r in self.processes):
if action != 'stop':
return {'error': 'Background processes cannot be steered; stop them or read the log.'}
return procs.kill(target)
from tools.delegate_tool_registry import _handle_control_action
return json.loads(_handle_control_action(action, target, message, getattr(self.cli, 'agent', None)))
def _counts(self, *, session=True):
"""Count fragment: ``2 live``, ``1 proc``, ``2 live · 3 procs``; the collapsed heading
(``session=True``) adds ``goal active|paused|parked`` and ``N queued``."""
parts = []
if self.entries:
parts.append(f'{len(self.entries)} live')
if self.processes:
running = sum(r['status'] == 'running' for r in self.processes)
parts.append(f"{running} proc{'s' if running != 1 else ''}" if running else f'{len(self.processes)} done')
if session and self.goal:
parts.append('goal ' + ('parked' if self.goal.startswith('⏳') else
'paused' if self.goal.startswith('⏸') else 'active'))
if session and self.queued:
parts.append(f'{len(self.queued)} queued')
return ' · '.join(parts)
def _title(self):
if self.entries and self.processes:
return 'Live work'
return 'Subagents' if self.entries else 'Processes'
def _collapsed_activity(self):
if self.entries:
row = self.entries[0]
return f"last: {row['last_tool']}" if row.get('last_tool') else row.get('status') or 'starting'
if self.processes:
return procs.process_activity(self.processes[0])
return self.goal or f'next: {self.queued[0]}'
def dock_text(self, *, columns, rows):
if not self.has_rows:
return ''
if self.collapsed:
count = self._counts()
# Keep both controls before spending scarce cells on activity. Ctrl+T opens the
# subagent/process monitor, so a goal/queue-only dock offers just F7.
if self.entries or self.processes:
headings = (
f'{self._title()} · {count} · Ctrl+T expand · F7 restore',
f'{count} · Ctrl+T expand · F7 restore',
f'{count} · Ctrl+T · F7',
count,
)
else:
headings = (f'{count} · F7 restore', f'{count} · F7', count)
width = max(0, columns - 1)
heading = next((text for text in headings if get_cwidth(text) <= width), count)
activity = self._collapsed_activity()
# A goal/queue preview is long prose: clip it into the room left instead of dropping it.
room = width - get_cwidth(heading + ' · ')
if get_cwidth(activity) <= room or (room >= 12 and not (self.entries or self.processes)):
heading += ' · ' + _clip(activity, room)
return _clip(' ' + heading, max(0, columns))
columns = max(0, columns - 2)
lines = [_clip(f' {self.goal}', columns)] if self.goal else []
budget = max(1, min(4, (rows - 10) // 3))
# Both blocks present: split the row budget so neither hides the other entirely.
agent_budget = budget if not self.processes else max(1, budget - max(1, budget // 2))
agent_count = min(len(self.entries), agent_budget)
if self.entries:
hidden = len(self.entries) - agent_count
lines.append(_clip(f' Subagents · {len(self.entries)} live · Ctrl+T expand · F7 collapse', columns))
for row in self.entries[:agent_count]:
activity = f"{row['elapsed']}s · " + (f"last: {row['last_tool']}" if row['last_tool'] else row.get('status') or 'starting')
# Reserve activity even on narrow terminals; task names use the remainder.
goal_width = max(3, columns - get_cwidth(activity) - 5)
lines.append(_clip(f" ● {_clip(row.get('goal'), goal_width)} · {activity}", columns))
if hidden:
lines.append(_clip(f' +{hidden} more · Ctrl+T all subagents', columns))
if self.processes:
proc_count = min(len(self.processes), max(1, budget - agent_count))
running = sum(r['status'] == 'running' for r in self.processes)
done = len(self.processes) - running
summary = ' · '.join(p for p in (f'{running} running' if running else '', f'{done} done' if done else '') if p)
controls = ' · Ctrl+T expand · F7 collapse' if not self.entries else ''
lines.append(_clip(f' Processes · {summary}{controls}', columns))
for row in self.processes[:proc_count]:
activity = procs.process_activity(row)
command_width = max(3, columns - get_cwidth(activity) - 5)
lines.append(_clip(f" {procs.process_glyph(row)} {_clip(row['command'], command_width)} · {activity}", columns))
if len(self.processes) > proc_count:
lines.append(_clip(f' +{len(self.processes) - proc_count} more · Ctrl+T all processes', columns))
if self.queued:
# Last, so the next prompt to run sits right above the input it came from.
shown = min(len(self.queued), session_rows.QUEUE_ROWS if rows >= 24 else 1)
controls = '' if self.entries or self.processes else ' · F7 collapse'
lines.append(_clip(f' Queue · {len(self.queued)} queued · /queue list{controls}', columns))
for index, text in enumerate(self.queued[:shown], 1):
lines.append(_clip(f' {index}. {text}', columns))
if len(self.queued) > shown:
lines.append(_clip(f' +{len(self.queued) - shown} more', columns))
return '\n'.join(' ' + line for line in lines)
def read_tail(path):
if not path:
return 'Live transcript not available yet.'
try:
with open(path, 'rb') as stream:
stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 32768))
text = stream.read(32768).decode('utf-8', errors='replace')
return ''.join(c for c in text if c.isprintable() or c in '\n\t')
except OSError:
return 'Live transcript not available yet.'
def modal_prompt_active(cli):
return any(getattr(cli, name, None) for name in (
'_clarify_state', '_approval_state', '_slash_confirm_state', '_sudo_state',
'_secret_state', '_model_picker_state', '_command_palette_state'))
def build_monitor_application(monitor, **kwargs):
from prompt_toolkit.application import Application
from prompt_toolkit.data_structures import Point
from prompt_toolkit.filters import Condition
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.widgets import TextArea
state = {'detail': False, 'steering': False, 'confirm': False, 'notice': ''}
steer = TextArea(height=1, prompt='Steer: ', multiline=False)
tail = TextArea(read_only=True, scrollbar=True, wrap_lines=True)
def roster_text():
size = app.output.get_size()
rows = []
for row in monitor.roster:
selected = monitor._key(row) == monitor.selected_id
if row.get('kind') == 'process':
prefix = f"{procs.process_glyph(row)} {procs.process_activity(row)} · "
activity = ''
subject = row['command']
else:
prefix = f"{row['elapsed']}s · {row.get('status') or 'starting'} · "
activity = f" · last: {row['last_tool']}" if row.get('last_tool') else ''
subject = row.get('goal') or row['subagent_id']
goal_width = max(0, size.columns - 2 - get_cwidth(prefix + activity))
text = f"{'❯' if selected else ' '} " + _clip(prefix + _clip(subject, goal_width) + activity, max(0, size.columns - 2))
# Pad selection in terminal cells, not codepoints (task names may be wide).
text += ' ' * max(0, size.columns - get_cwidth(text))
rows.append(('class:subagent-dock.selected' if selected else '', text + '\n'))
return rows or [('', 'No live subagents or background processes. Results arrive in the conversation.')]
def cursor():
index = next((i for i, row in enumerate(monitor.roster) if monitor._key(row) == monitor.selected_id), 0)
return Point(x=0, y=index)
roster = Window(FormattedTextControl(roster_text, focusable=True, get_cursor_position=cursor))
def update_tail():
row = monitor.selected
if row is None:
text = 'This entry is no longer live.'
elif row.get('kind') == 'process':
text = procs.process_tail(row['id'])
else:
text = read_tail(row.get('live_transcript'))
if text != tail.text:
following = tail.buffer.cursor_position == len(tail.text)
position = tail.buffer.cursor_position
tail.text = text
tail.buffer.cursor_position = len(text) if following else min(position, len(text))
def header():
row = monitor.selected
title = f"{monitor._title()} · {monitor._counts(session=False)}"
if state['detail'] and row:
title += f" · {monitor._key(row)} · {row.get('goal') or row.get('command') or ''}"
return [('class:subagent-dock.heading', _clip(title, app.output.get_size().columns))]
def footer():
narrow = app.output.get_size().columns < 60
process = monitor.selected_process is not None
if state['confirm']:
noun = 'process' if process else 'subagent'
return 'Stop? y yes · Esc cancel' if narrow else f'Stop selected {noun}? y confirm · Esc cancel'
if state['steering']:
return 'Enter send · Esc cancel' if narrow else 'Enter queues guidance · Esc cancels (does not interrupt)'
steer = '' if process else ' s steer'
if narrow:
return f'PgUp/Dn ·{steer} x stop · Esc' if state['detail'] else '↑↓ · Enter tail · Ctrl+T close'
steer = '' if process else ' · s steer'
return (f'Esc roster · PgUp/PgDn tail{steer} · x stop' if state['detail'] else
f'↑/↓ select · Enter tail{steer} · x stop · q/Ctrl+T close')
kb = KeyBindings()
normal = Condition(lambda: not state['steering'] and not state['confirm'])
listing = normal & Condition(lambda: not state['detail'])
@kb.add('up', filter=listing)
def up(event):
monitor.select(-1)
@kb.add('down', filter=listing)
def down(event):
monitor.select(1)
@kb.add('enter', filter=listing)
def detail(event):
if monitor.selected:
state['detail'] = True
update_tail()
app.layout.focus(tail)
@kb.add('s', filter=normal)
def start_steer(event):
if monitor.selected and monitor.selected_process is None:
state['steering'] = True
state['target'] = monitor.selected_id
app.layout.focus(steer)
@kb.add('enter', filter=Condition(lambda: state['steering']))
def send_steer(event):
if not steer.text.strip():
return
result = monitor.control('steer', steer.text, target=state['target'])
state['notice'] = result.get('error') or result.get('note') or str(result)
steer.text = ''
state['steering'] = False
app.layout.focus(tail if state['detail'] else roster)
@kb.add('x', filter=normal)
def stop(event):
if monitor.selected:
state['confirm'] = True
state['target'] = monitor.selected_id
@kb.add('y', filter=Condition(lambda: state['confirm']))
def confirm(event):
result = monitor.control('stop', target=state['target'])
state['notice'] = result.get('error') or result.get('note') or str(result)
state['confirm'] = False
@kb.add('escape', eager=True)
def back(event):
if state['steering'] or state['confirm']:
state['steering'] = state['confirm'] = False
app.layout.focus(tail if state['detail'] else roster)
elif state['detail']:
state['detail'] = False
app.layout.focus(roster)
else:
app.exit()
@kb.add('q', filter=normal)
@kb.add('f6', filter=normal)
@kb.add('c-t', filter=normal)
@kb.add('c-c')
def close(event):
app.exit()
layout = Layout(HSplit([
Window(FormattedTextControl(header), height=1),
ConditionalContainer(roster, filter=Condition(lambda: not state['detail'])),
ConditionalContainer(tail, filter=Condition(lambda: state['detail'])),
ConditionalContainer(steer, filter=Condition(lambda: state['steering'])),
Window(FormattedTextControl(lambda: _clip(state['notice'], app.output.get_size().columns)), height=1),
Window(FormattedTextControl(footer), height=1),
], style='class:subagent-dock'), focused_element=roster)
def before_render(app):
# Prompts arrive on worker threads; exit on the UI loop, including the
# first frame if a prompt won the race with in_terminal() acquisition.
if modal_prompt_active(monitor.cli) and not app.is_done:
app.exit()
elif state['detail']:
update_tail()
from prompt_toolkit.styles import Style
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
kwargs.setdefault('style', Style.from_dict(get_prompt_toolkit_style_overrides()))
app = Application(layout=layout, key_bindings=kb, full_screen=True, mouse_support=False,
before_render=before_render, **kwargs)
return app
def open_monitor(cli):
import asyncio
from prompt_toolkit.application import in_terminal
monitor = getattr(cli, '_subagent_monitor', None)
if monitor is None or monitor.opening:
return
monitor.opening = True
async def run():
try:
async with in_terminal():
monitor.refresh()
monitor.app = build_monitor_application(monitor)
await monitor.app.run_async()
finally:
monitor.app = None
monitor.opening = False
cli._invalidate()
asyncio.get_running_loop().create_task(run())
def toggle_dock(cli):
monitor = getattr(cli, '_subagent_monitor', None)
if monitor is not None:
monitor.collapsed = not monitor.collapsed
cli._invalidate()
def install_dock(cli):
from prompt_toolkit.application import get_app
from prompt_toolkit.layout import ConditionalContainer, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.filters import Condition
monitor = SubagentMonitor(cli)
cli._subagent_monitor = monitor
monitor.refresh()
def text():
size = get_app().output.get_size()
lines = monitor.dock_text(columns=size.columns, rows=size.rows).splitlines()
return [('class:subagent-dock.heading' if i == 0 else '',
line + ('\n' if i < len(lines) - 1 else ''))
for i, line in enumerate(lines)]
cli._subagent_dock_widget = ConditionalContainer(
Window(FormattedTextControl(text), wrap_lines=False, dont_extend_height=True,
style='class:subagent-dock'),
filter=Condition(lambda: monitor.has_rows and not modal_prompt_active(cli)),
)