From caa2e2dd99a402b8dc5fe4b3f95e49a1fb56d35a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:32:19 -0700 Subject: [PATCH] feat(cli): background processes show in the live-work dock and monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/cli_subagent_monitor.py | 161 +++++++++++++++++++------- tests/hermes_cli/test_process_dock.py | 2 +- 2 files changed, 121 insertions(+), 42 deletions(-) diff --git a/hermes_cli/cli_subagent_monitor.py b/hermes_cli/cli_subagent_monitor.py index 064c415c3b..1aef81e56e 100644 --- a/hermes_cli/cli_subagent_monitor.py +++ b/hermes_cli/cli_subagent_monitor.py @@ -1,4 +1,5 @@ -"""Classic CLI subagent dock and scoped controls; no agent-loop state is changed.""" +"""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 @@ -6,6 +7,8 @@ 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()) @@ -24,6 +27,7 @@ class SubagentMonitor: def __init__(self, cli): self.cli = cli self.entries = [] + self.processes = [] self.selected_id = None self._signature = None self._last_poll = 0 @@ -31,9 +35,23 @@ class SubagentMonitor: 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.entries if r['subagent_id'] == self.selected_id), None) + 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 @@ -47,13 +65,17 @@ class SubagentMonitor: 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) - signature = json.dumps(entries, sort_keys=True, default=str) + 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: - self.selected_id = entries[0]['subagent_id'] if entries else None + roster = self.roster + self.selected_id = self._key(roster[0]) if roster else None return changed def invalidate(self): @@ -77,45 +99,88 @@ class SubagentMonitor: 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'] + 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 or self.selected_id, message, getattr(self.cli, 'agent', None))) + 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: + if not self.entries and not self.processes: return '' if self.collapsed: - count = f'{len(self.entries)} live' + count = self._counts() # Keep both controls before spending scarce cells on activity. headings = ( - f'Subagents · {count} · Ctrl+T expand · F7 restore', + 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) - row = self.entries[0] - activity = f"last: {row['last_tool']}" if row.get('last_tool') else row.get('status') or 'starting' + activity = self._collapsed_activity() 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)) + 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) @@ -154,27 +219,37 @@ def build_monitor_application(monitor, **kwargs): 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 '' + 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)) - 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)) + 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. Results arrive in the conversation.')] + 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.entries) if row['subagent_id'] == monitor.selected_id), 0) + 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 - text = read_tail(row.get('live_transcript')) if row else 'This subagent is no longer live.' + 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 @@ -183,21 +258,25 @@ def build_monitor_application(monitor, **kwargs): def header(): row = monitor.selected - title = f"Subagents · {len(monitor.entries)} live" + title = f"{monitor._title()} · {monitor._counts()}" if state['detail'] and row: - title += f" · {row['subagent_id']} · {row.get('goal') or ''}" + 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']: - return 'Stop? y yes · Esc cancel' if narrow else 'Stop selected subagent? y confirm · Esc cancel' + 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 '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') + 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']) @@ -220,7 +299,7 @@ def build_monitor_application(monitor, **kwargs): @kb.add('s', filter=normal) def start_steer(event): - if monitor.selected: + if monitor.selected and monitor.selected_process is None: state['steering'] = True state['target'] = monitor.selected_id app.layout.focus(steer) @@ -337,5 +416,5 @@ def install_dock(cli): 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)), + filter=Condition(lambda: bool(monitor.entries or monitor.processes) and not modal_prompt_active(cli)), ) diff --git a/tests/hermes_cli/test_process_dock.py b/tests/hermes_cli/test_process_dock.py index e3e0ba2a13..ae6be28bb5 100644 --- a/tests/hermes_cli/test_process_dock.py +++ b/tests/hermes_cli/test_process_dock.py @@ -21,7 +21,7 @@ def test_dock_paints_processes_under_agents_and_retires_finished_rows(monkeypatc monkeypatch.setattr(registry, '_active_subagents', {}) owner = SimpleNamespace(session_id='owner') registry._register_subagent(dict(subagent_id='a1', owner_agent_session_id='owner', - goal='Check module', started_at=10, status='running', last_tool='read_file')) + goal='Check module', started_at=time.time() - 5, status='running', last_tool='read_file')) quick = process_registry.spawn_local(command="echo hello-dock; exit 3", cwd='.', task_id='t', owner_task_id='t', session_key='') slow = process_registry.spawn_local(command="sleep 30", cwd='.', task_id='t', owner_task_id='t', session_key='') quick_id, slow_id = quick.id, slow.id