Files
hermes-agent/hermes_cli/cli_subagent_monitor.py

342 lines
14 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 subagent dock and scoped controls; no agent-loop state is changed."""
from __future__ import annotations
import json
import time
from prompt_toolkit.utils import get_cwidth
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.selected_id = None
self._signature = None
self._last_poll = 0
self.app = None
self.opening = False
self.collapsed = False
@property
def selected(self):
return next((r for r in self.entries if r['subagent_id'] == self.selected_id), 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.pop('running_seconds', None)
signature = json.dumps(entries, sort_keys=True, default=str)
changed = signature != self._signature
self._signature = signature
self.entries = entries
if self.selected is None:
self.selected_id = entries[0]['subagent_id'] if entries 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):
if self.entries:
index = next((i for i, r in enumerate(self.entries) if r['subagent_id'] == self.selected_id), 0)
self.selected_id = self.entries[(index + delta) % len(self.entries)]['subagent_id']
def control(self, action, message=None, *, target=None):
from tools.delegate_tool_registry import _handle_control_action
return json.loads(_handle_control_action(action, target or self.selected_id, message, getattr(self.cli, 'agent', None)))
def dock_text(self, *, columns, rows):
if not self.entries:
return ''
if self.collapsed:
count = f'{len(self.entries)} live'
# Keep both controls before spending scarce cells on activity.
headings = (
f'Subagents · {count} · Ctrl+T expand · F7 restore',
f'{count} · Ctrl+T expand · F7 restore',
f'{count} · Ctrl+T · F7',
count,
)
width = max(0, columns - 1)
heading = next((text for text in headings if get_cwidth(text) <= width), count)
row = self.entries[0]
activity = f"last: {row['last_tool']}" if row.get('last_tool') else row.get('status') or 'starting'
if get_cwidth(heading + ' · ' + activity) <= width:
heading += ' · ' + activity
return _clip(' ' + heading, max(0, columns))
columns = max(0, columns - 2)
count = min(len(self.entries), max(1, min(4, (rows - 10) // 3)))
hidden = len(self.entries) - count
heading = f' Subagents · {len(self.entries)} live · Ctrl+T expand · F7 collapse'
lines = [_clip(heading, columns)]
for row in self.entries[: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))
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.entries:
selected = row['subagent_id'] == monitor.selected_id
prefix = f"{row['elapsed']}s · {row.get('status') or 'starting'} · "
activity = f" · last: {row['last_tool']}" if row.get('last_tool') else ''
goal_width = max(0, size.columns - 2 - get_cwidth(prefix + activity))
goal = _clip(row.get('goal') or row['subagent_id'], goal_width)
text = f"{'❯' if selected else ' '} " + _clip(prefix + goal + 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. Results arrive in the conversation.')]
def cursor():
index = next((i for i, row in enumerate(monitor.entries) if row['subagent_id'] == 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
text = read_tail(row.get('live_transcript')) if row else 'This subagent is no longer live.'
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"Subagents · {len(monitor.entries)} live"
if state['detail'] and row:
title += f" · {row['subagent_id']} · {row.get('goal') or ''}"
return [('class:subagent-dock.heading', _clip(title, app.output.get_size().columns))]
def footer():
narrow = app.output.get_size().columns < 60
if state['confirm']:
return 'Stop? y yes · Esc cancel' if narrow else 'Stop selected subagent? y confirm · Esc cancel'
if state['steering']:
return 'Enter send · Esc cancel' if narrow else 'Enter queues guidance · Esc cancels (does not interrupt)'
if narrow:
return 'PgUp/Dn · s steer x stop · Esc' if state['detail'] else '↑↓ · Enter tail · Ctrl+T close'
return ('Esc roster · PgUp/PgDn tail · s steer · x stop' if state['detail'] else
'↑/↓ select · Enter tail · s 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:
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: bool(monitor.entries) and not modal_prompt_active(cli)),
)