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.
456 lines
20 KiB
Python
456 lines
20 KiB
Python
"""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)),
|
||
)
|