Files
hermes-agent/hermes_cli/cli_subagent_monitor.py
teknium1 caa2e2dd99 feat(cli): background processes show in the live-work dock and monitor
terminal(background=true) spawns were only a "⚙ N" count in the status bar
while subagents got a dock, a roster, tails and controls. The classic CLI
dock now paints a Processes block under the subagent rows (command,
elapsed, latest output line; exit verdict once finished, retained 60 s),
and the Ctrl+T/F6 monitor lists processes under the agents with Enter =
log tail and x = stop that process. Processes cannot be steered.
2026-09-20 13:55:03 -07:00

421 lines
18 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) and scoped controls; no
agent-loop state is changed. Process rows come from ``cli_process_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
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.selected_id = None
self._signature = None
self._last_poll = 0
self.app = None
self.opening = False
self.collapsed = False
@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)
signature = json.dumps([entries, processes], sort_keys=True, default=str)
changed = signature != self._signature
self._signature = signature
self.entries = entries
self.processes = processes
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):
"""Collapsed-heading count fragment: ``2 live``, ``1 proc``, or ``2 live · 3 procs``."""
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')
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'
return procs.process_activity(self.processes[0])
def dock_text(self, *, columns, rows):
if not self.entries and not self.processes:
return ''
if self.collapsed:
count = self._counts()
# Keep both controls before spending scarce cells on activity.
headings = (
f'{self._title()} · {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)
activity = self._collapsed_activity()
if get_cwidth(heading + ' · ' + activity) <= width:
heading += ' · ' + activity
return _clip(' ' + heading, max(0, columns))
columns = max(0, columns - 2)
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)
lines = []
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))
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()}"
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: bool(monitor.entries or monitor.processes) and not modal_prompt_active(cli)),
)