#!/usr/bin/env python3
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
# 
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
"""
Hermes Node Agent - Reverse-connection node executor

Connects to Hermes Gateway via WebSocket and executes commands.
Supports optional tools: browser control, computer_control.

Author: Lisa (Hermes AI)
Date: 2026-04-30 (enhanced)
"""

import argparse
import asyncio
import base64
import json
import logging
import os
import shutil
import shlex
import ssl
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional, Dict, Any, List

LOG_PREVIEW_LEN = 120


def _preview_command(command: Any, limit: int = LOG_PREVIEW_LEN) -> str:
    """Return a compact single-line preview for logging."""
    if isinstance(command, (list, tuple)):
        text = ' '.join(str(part) for part in command)
    else:
        text = str(command)
    text = ' '.join(text.split())
    if len(text) > limit:
        return text[:limit] + '…'
    return text


def _setup_logging(debug: bool = False) -> None:
    level = logging.DEBUG if debug else logging.INFO
    logging.basicConfig(level=level, format='%(message)s')


def _log_start(node_name: str, tools: list) -> None:
    logger.info(f"start ▶ {node_name} — {', '.join(tools)}")


def _log_connect(url: str) -> None:
    logger.info(f"connect ▶ {url}")


def _log_tls_disabled() -> None:
    logger.info("tls verify disabled")


def _log_connected() -> None:
    logger.info("connect ✓")


def _log_disconnected(reason: Any = None) -> None:
    if reason:
        logger.info(f"disconnect — {reason}")
    else:
        logger.info("disconnect")


def _log_registering(node_name: str) -> None:
    logger.info(f"register ▶ {node_name}")


def _log_registered(node_name: str) -> None:
    logger.info(f"register ✓ {node_name}")


def _log_waiting() -> None:
    logger.info("waiting for commands")


def _log_exec_received(command: Any) -> None:
    logger.info(f"exec ▶ {_preview_command(command)}")


def _log_exec_completed(command: Any, exit_code: Any) -> None:
    logger.info(f"exec ✓ exit={exit_code} — {_preview_command(command)}")


def _log_exec_failed(command: Any, error: Any, exit_code: Any = None) -> None:
    prefix = f"exec ✗ exit={exit_code}" if exit_code is not None else "exec ✗"
    logger.error(f"{prefix} — {_preview_command(command)} — {error}")


def _log_tool_completed(tool_name: str, label: Any, success: bool, error: Any = None) -> None:
    mark = '✓' if success else '✗'
    suffix = f" — {error}" if error else ''
    logger.info(f"{tool_name} {mark} {_preview_command(label)}{suffix}")


def _log_browser_received(command: Any) -> None:
    logger.info(f"browser ▶ {_preview_command(command)}")


def _log_cc_received(action: Any) -> None:
    logger.info(f"computer ▶ {_preview_command(action)}")


def _log_audio_received(action: Any) -> None:
    logger.info(f"audio ▶ {_preview_command(action)}")


def _log_camera_received(action: Any) -> None:
    logger.info(f"camera ▶ {_preview_command(action)}")


def _log_reconnect(delay: Any, reason: Any) -> None:
    logger.info(f"reconnect in {delay}s — {reason}")


def _log_registration_ack() -> None:
    logger.debug("register ack")


def _log_heartbeat_ack() -> None:
    logger.debug("heartbeat ack")


def _log_unknown_message(req_type: Any) -> None:
    logger.warning(f"unknown message: {req_type}")


def _log_connection_error(message: Any) -> None:
    logger.error(f"connection error — {message}")


def _log_config_missing(path: Path) -> None:
    logger.error(f"config missing — {path}")


def _log_token_missing() -> None:
    logger.error("token missing in config")


def _log_init_warning(component: str, message: Any) -> None:
    logger.warning(f"{component} init failed — {message}")


def _log_disabled(component: str, message: str) -> None:
    logger.warning(f"{component} disabled — {message}")


def _log_shutdown() -> None:
    logger.info("shutdown")

logger = logging.getLogger(__name__)

try:
    import websockets
except ImportError:
    print("ERROR: websockets library not found. Install with: pip install websockets")
    sys.exit(1)

try:
    from browser_controller import BrowserController
    HAS_BROWSER = True
except ImportError:
    HAS_BROWSER = False

logger = logging.getLogger(__name__)

# ═════════════════════════════════════════════════════════════════════════
# DEFAULT CONFIGURATION
# ═════════════════════════════════════════════════════════════════════════

DEFAULT_GATEWAY_TOKEN = 'GATEWAY_TOKEN_MUST_BE_PROVIDED'

DEFAULT_CONFIG = {
    'gateway_url': 'wss://localhost:8765',
    'node_name': 'unknown',
    'token': DEFAULT_GATEWAY_TOKEN,
    'reconnect_interval': 5,
    'heartbeat_interval': 30,
    'gateway_cert_path': None,
    'capabilities': ['exec'],
    'enable_browser': False,
    'enable_computer_control': False,
    'enable_desktop_observe': False,
    'enable_audio_control': False,
    'enable_camera_control': False,
}

# ═════════════════════════════════════════════════════════════════════════
# PLATFORM ABSTRACTION LAYER
# ═════════════════════════════════════════════════════════════════════════

class PlatformError(RuntimeError):
    """Raised when platform-specific operations fail."""

def is_windows() -> bool:
    return sys.platform in ('win32', 'cygwin')

def is_linux() -> bool:
    return sys.platform.startswith('linux')

def is_macos() -> bool:
    return sys.platform == 'darwin'

# ═════════════════════════════════════════════════════════════════════════
# COMMAND EXECUTION ABSTRACTION
# ═════════════════════════════════════════════════════════════════════════

class CommandExecutor:
    """Abstract base class for executing commands with permission enforcement."""

    def __init__(self, permission_rules: Dict[str, List[str]]):
        self.permissions = permission_rules or {'allow': [], 'deny': [], 'ask': []}

    def execute(self, command: Any, approved: bool = False) -> Dict[str, Any]:
        """Execute command respecting permission rules."""
        raise NotImplementedError

    def _normalize_command_text(self, command: Any) -> str:
        if isinstance(command, (list, tuple)):
            return ' '.join(str(part) for part in command).strip()
        return str(command).strip()

    def _check_permission(self, command: Any, approved: bool) -> tuple[bool, str]:
        """Check allow/deny/ask rules.
        Returns (allowed, reason). 'ask' means requires approval gate.
        Accepts command as string or argv list.
        """
        import re
        cmd = self._normalize_command_text(command)
        # Deny (highest priority)
        for pattern in self.permissions.get('deny', []):
            if re.search(pattern, cmd, re.IGNORECASE):
                return False, f"Denied by pattern '{pattern}'"
        # Ask (medium) — only blocks if not approved
        if not approved:
            for pattern in self.permissions.get('ask', []):
                if re.search(pattern, cmd, re.IGNORECASE):
                    return True, 'ask'
        # Allow (explicit)
        if self.permissions.get('allow'):
            for pattern in self.permissions['allow']:
                if re.search(pattern, cmd, re.IGNORECASE):
                    return True, 'allowed'
            return False, "Not in allow list"
        return True, 'default-allow'


# ── POSIX ──────────────────────────────────────────────────────────────────
class PosixCommandExecutor(CommandExecutor):
    """POSIX implementation using integrated permission checks and /bin/sh."""

    def execute(self, command: Any, approved: bool = False) -> Dict[str, Any]:
        allowed, reason = self._check_permission(command, approved)
        if not allowed and reason != 'ask':
            return {'success': False, 'error': f'Permission denied: {reason}', 'exit_code': 127}
        if not approved and reason == 'ask':
            return {'success': False, 'error': 'Command requires approval', 'exit_code': 2}
        if isinstance(command, (list, tuple)):
            cmd = ' '.join(shlex.quote(str(part)) for part in command)
        else:
            cmd = str(command)
        try:
            proc = subprocess.Popen(
                ['/bin/sh', '-c', cmd],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )
            out, err = proc.communicate(timeout=300)
            return {
                'success': proc.returncode == 0,
                'stdout': out,
                'stderr': err,
                'exit_code': proc.returncode,
            }
        except subprocess.TimeoutExpired:
            try:
                proc.kill()
            except Exception:
                pass
            return {'success': False, 'error': 'Command timed out', 'exit_code': 124}
        except Exception as e:
            return {'success': False, 'error': str(e), 'exit_code': -1}


# ── WINDOWS ────────────────────────────────────────────────────────────────
class WindowsCommandExecutor(CommandExecutor):
    """Windows implementation using PowerShell with base64-encoded commands.

    Design:
    - Uses PowerShell 7+ (pwsh.exe) if available, else Windows PowerShell
    - Encodes command as base64(utf-16le) to avoid shell quoting issues
    - No-Persist policy flags make it a child process
    - Returns stdout/stderr as text with exit code
    """

    def __init__(self, permission_rules: Dict[str, List[str]]):
        super().__init__(permission_rules)
        self.powershell = self._find_powershell()

    def _find_powershell(self) -> str:
        for candidate in [
            r'C:\Program Files\PowerShell\7\pwsh.exe',
            r'C:\Program Files (x86)\PowerShell\7\pwsh.exe',
            r'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe',
        ]:
            if Path(candidate).exists():
                return candidate
        return 'powershell'

    def execute(self, command: str, approved: bool = False) -> Dict[str, Any]:
        allowed, reason = self._check_permission(command, approved)
        if not allowed and reason != 'ask':
            return {'success': False, 'error': f'Permission denied: {reason}', 'exit_code': 127}
        if not approved and reason == 'ask':
            return {'success': False, 'error': 'Command requires approval', 'exit_code': 2}
        try:
            import base64
            encoded = base64.b64encode(command.encode('utf-16le')).decode('ascii')
            proc = subprocess.Popen(
                [self.powershell, '-NoProfile', '-NonInteractive',
                 '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
                creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
            )
            out, err = proc.communicate(timeout=300)
            return {'success': proc.returncode == 0, 'stdout': out, 'stderr': err,
                    'exit_code': proc.returncode}
        except FileNotFoundError:
            return {'success': False, 'error': 'PowerShell not found', 'exit_code': 127}
        except Exception as e:
            return {'success': False, 'error': str(e), 'exit_code': -1}


# ═════════════════════════════════════════════════════════════════════════
# COMPUTER CONTROL LAYER — cross-platform abstraction
# ═════════════════════════════════════════════════════════════════════════

class ComputerControllerBase:
    """Base class for desktop automation, platform-agnostic API."""

    def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
        raise NotImplementedError

    def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
        raise NotImplementedError

    def mouse_click(self, button: int = 1) -> Dict[str, Any]:
        raise NotImplementedError

    def mouse_position(self) -> Dict[str, Any]:
        raise NotImplementedError

    def type_text(self, text: str) -> Dict[str, Any]:
        raise NotImplementedError

    def key_press(self, key: str) -> Dict[str, Any]:
        raise NotImplementedError

    def get_active_window(self) -> Dict[str, Any]:
        raise NotImplementedError


# ── POSIX computer control (xdotool + import/ImageMagick) ──────────────────
class PosixComputerController(ComputerControllerBase):
    """Linux X11 automation via command-line tools."""

    def __init__(self):
        self.display = os.environ.get('DISPLAY', ':0')

    def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
        if output_path:
            r = self._run(f'import -display {self.display} -window root "{output_path}"')
            return {'success': r['success'], 'path': output_path, 'error': r.get('error')}
        else:
            import base64
            try:
                result = subprocess.run(
                    f'import -display {self.display} -window root png:-',
                    shell=True, capture_output=True, timeout=30
                )
                if result.returncode == 0:
                    return {
                        'success': True, 'format': 'png',
                        'data': base64.b64encode(result.stdout).decode('ascii'),
                        'size': len(result.stdout)
                    }
            except Exception as e:
                return {'success': False, 'error': str(e)}
            return {'success': False, 'error': 'screenshot failed'}

    def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
        return self._run(f'xdotool mousemove {x} {y}')

    def mouse_click(self, button: int = 1) -> Dict[str, Any]:
        return self._run(f'xdotool click {button}')

    def mouse_position(self) -> Dict[str, Any]:
        out = self._run('xdotool getmouselocation --shell')
        pos = {}
        if out['success']:
            for line in out['stdout'].splitlines():
                if '=' in line:
                    k, v = line.split('=', 1)
                    pos[k] = int(v)
        return {'success': out['success'], 'position': pos, 'error': out.get('error')}

    def type_text(self, text: str) -> Dict[str, Any]:
        # Escape single quotes for shell
        safe = text.replace("'", "'\"'\"'")
        return self._run(f"xdotool type --delay 1 '{safe}'")

    def key_press(self, key: str) -> Dict[str, Any]:
        return self._run(f'xdotool key {key}')

    def get_active_window(self) -> Dict[str, Any]:
        win_id = self._run('xdotool getactivewindow')
        if win_id['success']:
            title = self._run(f'xdotool getwindowname {win_id["stdout"]}')
            return {'success': True, 'window_id': win_id['stdout'],
                    'title': title.get('stdout', ''), 'error': title.get('error')}
        return win_id

    def _run(self, cmd: str) -> Dict[str, Any]:
        try:
            r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
            return {'success': r.returncode == 0, 'stdout': r.stdout.strip(),
                    'stderr': r.stderr.strip(), 'exit_code': r.returncode}
        except Exception as e:
            return {'success': False, 'error': str(e)}


# ── WINDOWS computer control (pyautogui + PIL) ────────────────────────────
class WindowsComputerController(ComputerControllerBase):
    """Windows desktop automation using pyautogui and PIL/Pillow."""

    def __init__(self):
        try:
            import PIL.ImageGrab
            import pyautogui
            self.ImageGrab = PIL.ImageGrab
            self.pyautogui = pyautogui
            pyautogui.FAILSAFE = False  # Allow user to abort by moving mouse to corner
        except ImportError as e:
            raise ImportError(f"Windows computer_control requires pyautogui and Pillow: {e}")

    def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
        try:
            img = self.ImageGrab.grab()
            if output_path:
                img.save(output_path)
                return {'success': True, 'path': output_path}
            import io, base64
            buf = io.BytesIO()
            img.save(buf, format='PNG')
            return {
                'success': True, 'format': 'png',
                'data': base64.b64encode(buf.getvalue()).decode('ascii'),
                'size': len(buf.getvalue())
            }
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
        try:
            self.pyautogui.moveTo(x, y)
            return {'success': True}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def mouse_click(self, button: int = 1) -> Dict[str, Any]:
        try:
            btn = {1: 'left', 2: 'middle', 3: 'right'}.get(button, 'left')
            self.pyautogui.click(button=btn)
            return {'success': True}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def mouse_position(self) -> Dict[str, Any]:
        try:
            x, y = self.pyautogui.position()
            return {'success': True, 'position': {'x': x, 'y': y}}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def type_text(self, text: str) -> Dict[str, Any]:
        try:
            self.pyautogui.write(text, interval=0.01)
            return {'success': True}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def key_press(self, key: str) -> Dict[str, Any]:
        try:
            key_map = {
                'return': 'enter', 'enter': 'enter', 'esc': 'escape',
                'ctrl': 'ctrl', 'alt': 'alt', 'shift': 'shift',
                'tab': 'tab', 'space': 'space', 'backspace': 'backspace',
                'delete': 'delete', 'up': 'up', 'down': 'down',
                'left': 'left', 'right': 'right', 'home': 'home', 'end': 'end'
            }
            mapped = key_map.get(key.lower(), key)
            self.pyautogui.press(mapped)
            return {'success': True}
        except Exception as e:
            return {'success': False, 'error': str(e)}

    def get_active_window(self) -> Dict[str, Any]:
        try:
            import win32gui, win32process
            hwnd = win32gui.GetForegroundWindow()
            title = win32gui.GetWindowText(hwnd)
            _, pid = win32process.GetWindowThreadProcessId(hwnd)
            return {
                'success': True,
                'window_id': hwnd,
                'title': title,
                'process_id': pid
            }
        except ImportError:
            return {'success': True, 'title': self.pyautogui.getActiveWindow().title}
        except Exception as e:
            return {'success': False, 'error': str(e)}


# ── AUDIO CONTROL ──────────────────────────────────────────────────────────
class AudioControllerBase:
    """Base class for audio device/media actions."""

    def capability_info(self) -> Dict[str, Any]:
        raise NotImplementedError

    def list_audio_devices(self) -> Dict[str, Any]:
        raise NotImplementedError

    def get_audio_status(self) -> Dict[str, Any]:
        raise NotImplementedError

    def capture_output(self, params: Dict[str, Any]) -> Dict[str, Any]:
        raise NotImplementedError

    def capture_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
        raise NotImplementedError

    def play_audio(self, params: Dict[str, Any]) -> Dict[str, Any]:
        raise NotImplementedError


class CameraControllerBase:
    """Base class for camera device/media actions."""

    def capability_info(self) -> Dict[str, Any]:
        raise NotImplementedError

    def list_cameras(self) -> Dict[str, Any]:
        raise NotImplementedError

    def get_camera_status(self) -> Dict[str, Any]:
        raise NotImplementedError

    def capture_frame(self, params: Dict[str, Any]) -> Dict[str, Any]:
        raise NotImplementedError

    def capture_video(self, params: Dict[str, Any]) -> Dict[str, Any]:
        raise NotImplementedError


class PosixAudioController(AudioControllerBase):
    """Linux audio support via ffmpeg + available host backends."""

    def __init__(self):
        self.ffmpeg = shutil.which('ffmpeg')
        if not self.ffmpeg:
            raise PlatformError('ffmpeg not found')
        self.ffplay = shutil.which('ffplay')
        self.ffprobe = shutil.which('ffprobe')
        self.pactl = shutil.which('pactl')
        self.arecord = shutil.which('arecord')
        self.aplay = shutil.which('aplay')
        self.backend = self._detect_backend()

    def _detect_backend(self) -> str:
        if self.pactl:
            probe = self._run_quiet([self.pactl, 'info'])
            if probe['success']:
                server = (probe.get('stdout') or '').lower()
                if 'pipewire' in server:
                    return 'pipewire-pulse'
                return 'pulseaudio'
        if os.environ.get('PIPEWIRE_RUNTIME_DIR') or os.environ.get('XDG_RUNTIME_DIR'):
            return 'pipewire'
        if self.arecord:
            return 'alsa'
        return 'unknown'

    def capability_info(self) -> Dict[str, Any]:
        monitor_ready, monitor_name = self._default_monitor_source()
        input_ready, input_source = self._default_input_source()
        return {
            'platform': 'linux',
            'backend': self.backend,
            'available': True,
            'can_capture_output': monitor_ready,
            'can_capture_input': input_ready,
            'can_play_audio': bool(self.ffplay or self.aplay or self.ffmpeg),
            'can_inject_mic': False,
            'capture_output_ready': monitor_ready,
            'capture_output_backend': 'pulseaudio-monitor' if monitor_ready else None,
            'default_output_monitor': monitor_name,
            'default_input_source': input_source,
            'ffmpeg': bool(self.ffmpeg),
            'ffplay': bool(self.ffplay),
            'pactl': bool(self.pactl),
            'arecord': bool(self.arecord),
            'aplay': bool(self.aplay),
        }

    def _run_quiet(self, cmd: List[str], timeout: int = 15) -> Dict[str, Any]:
        try:
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            return {
                'success': proc.returncode == 0,
                'stdout': proc.stdout,
                'stderr': proc.stderr,
                'exit_code': proc.returncode,
            }
        except Exception as e:
            return {'success': False, 'stdout': '', 'stderr': str(e), 'exit_code': -1}

    def _default_output_sink(self) -> Optional[str]:
        if not self.pactl:
            return None
        result = self._run_quiet([self.pactl, 'get-default-sink'])
        sink = (result.get('stdout') or '').strip()
        if result['success'] and sink:
            return sink
        return None

    def _default_input_source(self) -> tuple[bool, Optional[str]]:
        if self.pactl:
            result = self._run_quiet([self.pactl, 'get-default-source'])
            source = (result.get('stdout') or '').strip()
            if result['success'] and source:
                return True, source
        if self.arecord:
            return True, 'default'
        return False, None

    def _default_monitor_source(self) -> tuple[bool, Optional[str]]:
        sink = self._default_output_sink()
        if sink:
            return True, f'{sink}.monitor'
        return False, None

    def _expand_output_path(self, path: Optional[str], suffix: str) -> str:
        if path:
            return str(Path(path).expanduser())
        stamp = int(time.time())
        return f'/tmp/hermes-audio-{stamp}{suffix}'

    def _encode_file(self, path: str) -> Dict[str, Any]:
        data = Path(path).read_bytes()
        return {
            'path': path,
            'size_bytes': len(data),
            'data_base64': base64.b64encode(data).decode('ascii'),
        }

    def _media_duration(self, path: str) -> Optional[float]:
        if not self.ffprobe:
            return None
        result = self._run_quiet([
            self.ffprobe, '-v', 'error', '-show_entries', 'format=duration',
            '-of', 'default=noprint_wrappers=1:nokey=1', path
        ])
        if not result['success']:
            return None
        try:
            return round(float((result.get('stdout') or '').strip()), 3)
        except Exception:
            return None

    def list_audio_devices(self) -> Dict[str, Any]:
        devices: Dict[str, Any] = {'backend': self.backend, 'sinks': [], 'sources': []}
        if self.pactl:
            sink_res = self._run_quiet([self.pactl, 'list', 'short', 'sinks'])
            source_res = self._run_quiet([self.pactl, 'list', 'short', 'sources'])
            if sink_res['success']:
                for line in sink_res.get('stdout', '').splitlines():
                    parts = line.split('\t')
                    if len(parts) >= 2:
                        devices['sinks'].append({'id': parts[0], 'name': parts[1], 'raw': line})
            if source_res['success']:
                for line in source_res.get('stdout', '').splitlines():
                    parts = line.split('\t')
                    if len(parts) >= 2:
                        devices['sources'].append({'id': parts[0], 'name': parts[1], 'raw': line})
        if not devices['sources'] and self.arecord:
            src = self._run_quiet([self.arecord, '-l'])
            devices['sources_raw'] = src.get('stdout', '')
        return {'success': True, **devices}

    def get_audio_status(self) -> Dict[str, Any]:
        monitor_ready, monitor_name = self._default_monitor_source()
        input_ready, input_source = self._default_input_source()
        status = {
            'success': True,
            'backend': self.backend,
            'default_output_sink': self._default_output_sink(),
            'default_output_monitor': monitor_name,
            'default_input_source': input_source,
            'capture_output_ready': monitor_ready,
            'capture_input_ready': input_ready,
            'can_play_audio': bool(self.ffplay or self.aplay or self.ffmpeg),
        }
        if self.pactl:
            info = self._run_quiet([self.pactl, 'info'])
            if info['success']:
                status['server_info'] = info.get('stdout', '')
        return status

    def capture_output(self, params: Dict[str, Any]) -> Dict[str, Any]:
        duration = max(1, min(int(params.get('duration', 5)), 600))
        fmt = str(params.get('format', 'wav')).lower()
        path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{fmt}')
        monitor_ready, monitor = self._default_monitor_source()
        if not monitor_ready or not monitor:
            return {'success': False, 'error': 'No PulseAudio/PipeWire monitor source available for output capture'}
        cmd = [
            self.ffmpeg, '-y', '-v', 'error', '-f', 'pulse', '-i', monitor,
            '-t', str(duration), path,
        ]
        result = self._run_quiet(cmd, timeout=duration + 15)
        if not result['success']:
            return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'ffmpeg capture failed').strip()}
        payload = {
            'success': True,
            'format': fmt,
            'duration': duration,
            'source': monitor,
        }
        payload.update(self._encode_file(path))
        media_duration = self._media_duration(path)
        if media_duration is not None:
            payload['measured_duration'] = media_duration
        return payload

    def capture_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
        duration = max(1, min(int(params.get('duration', 5)), 600))
        fmt = str(params.get('format', 'wav')).lower()
        path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{fmt}')
        source = params.get('source')
        input_ready, default_source = self._default_input_source()
        if not source:
            source = default_source
        if self.pactl and source:
            cmd = [
                self.ffmpeg, '-y', '-v', 'error', '-f', 'pulse', '-i', str(source),
                '-t', str(duration), path,
            ]
            result = self._run_quiet(cmd, timeout=duration + 15)
        elif self.arecord and input_ready:
            cmd = [self.arecord, '-q', '-d', str(duration), path]
            result = self._run_quiet(cmd, timeout=duration + 15)
        else:
            return {'success': False, 'error': 'No usable input capture backend available'}
        if not result['success']:
            return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'input capture failed').strip()}
        payload = {
            'success': True,
            'format': fmt,
            'duration': duration,
            'source': source,
        }
        payload.update(self._encode_file(path))
        media_duration = self._media_duration(path)
        if media_duration is not None:
            payload['measured_duration'] = media_duration
        return payload

    def play_audio(self, params: Dict[str, Any]) -> Dict[str, Any]:
        path = params.get('path')
        if not path:
            return {'success': False, 'error': 'play_audio requires params.path'}
        path = str(Path(path).expanduser())
        if not Path(path).exists():
            return {'success': False, 'error': f'Audio file not found: {path}'}
        if self.ffplay:
            cmd = [self.ffplay, '-nodisp', '-autoexit', '-loglevel', 'error', path]
        elif self.aplay:
            cmd = [self.aplay, path]
        else:
            cmd = [self.ffmpeg, '-v', 'error', '-i', path, '-f', 'null', '-']
        result = self._run_quiet(cmd, timeout=max(30, int(params.get('timeout', 120))))
        if not result['success']:
            return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'audio playback failed').strip()}
        payload = {'success': True, 'path': path}
        media_duration = self._media_duration(path)
        if media_duration is not None:
            payload['duration'] = media_duration
        return payload


# ── Factory functions ─────────────────────────────────────────────────────
def make_executor(config: Dict[str, Any]) -> CommandExecutor:
    """Select appropriate command executor for current platform."""
    perms = config.get('permissions', {})
    if is_windows():
        return WindowsCommandExecutor(perms)
    return PosixCommandExecutor(perms)

def make_computer_controller(config: Dict[str, Any]) -> Optional[ComputerControllerBase]:
    """Select and instantiate the appropriate computer controller, or None if deps missing."""
    if not config.get('enable_computer_control'):
        return None
    if is_windows():
        try:
            return WindowsComputerController()
        except ImportError as e:
            logger.warning(f"computer_control disabled (missing deps): {e}")
            return None
    else:
        # Linux/macOS
        if is_macos():
            logger.warning("macOS computer_control not implemented yet")
            return None
        # Linux
        if subprocess.run(['which', 'xdotool'], capture_output=True).returncode != 0:
            logger.warning("xdotool not found — computer_control disabled")
            return None
        try:
            return PosixComputerController()
        except Exception as e:
            logger.warning(f"computer_control init failed: {e}")
            return None


def make_audio_controller(config: Dict[str, Any]) -> Optional[AudioControllerBase]:
    if not config.get('enable_audio_control'):
        return None
    if is_linux():
        try:
            return PosixAudioController()
        except Exception as e:
            _log_disabled('audio_control', str(e))
            return None
    _log_disabled('audio_control', f'unsupported platform: {sys.platform}')
    return None


class PosixCameraController(CameraControllerBase):
    """Linux camera support via ffmpeg + V4L2 device nodes."""

    def __init__(self):
        self.ffmpeg = shutil.which('ffmpeg')
        if not self.ffmpeg:
            raise PlatformError('ffmpeg not found')
        self.ffprobe = shutil.which('ffprobe')
        self.v4l2_ctl = shutil.which('v4l2-ctl')

    def _list_device_paths(self) -> List[Path]:
        return sorted(Path('/dev').glob('video*'), key=lambda p: p.name)

    def _encode_file(self, path: str) -> Dict[str, Any]:
        data = Path(path).read_bytes()
        return {
            'path': path,
            'size_bytes': len(data),
            'data_base64': base64.b64encode(data).decode('ascii'),
        }

    def _media_duration(self, path: str) -> Optional[float]:
        if not self.ffprobe:
            return None
        try:
            proc = subprocess.run([
                self.ffprobe, '-v', 'error', '-show_entries', 'format=duration',
                '-of', 'default=noprint_wrappers=1:nokey=1', path
            ], capture_output=True, text=True, timeout=15)
            if proc.returncode != 0:
                return None
            return round(float((proc.stdout or '').strip()), 3)
        except Exception:
            return None

    def _expand_output_path(self, path: Optional[str], suffix: str) -> str:
        if path:
            return str(Path(path).expanduser())
        stamp = int(time.time())
        return f'/tmp/hermes-camera-{stamp}{suffix}'

    def _ffmpeg_probe(self, device: str) -> Dict[str, Any]:
        try:
            proc = subprocess.run(
                [self.ffmpeg, '-hide_banner', '-f', 'v4l2', '-list_formats', 'all', '-i', device],
                capture_output=True,
                text=True,
                timeout=15,
            )
            text = '\n'.join(part for part in [proc.stdout, proc.stderr] if part)
            return {
                'success': proc.returncode in (0, 1),
                'output': text.strip(),
                'exit_code': proc.returncode,
            }
        except Exception as e:
            return {'success': False, 'output': str(e), 'exit_code': -1}

    def _device_info(self, device_path: Path) -> Dict[str, Any]:
        info = {
            'path': str(device_path),
            'name': device_path.name,
            'exists': device_path.exists(),
            'readable': os.access(device_path, os.R_OK),
            'writable': os.access(device_path, os.W_OK),
        }
        by_id_root = Path('/dev/v4l/by-id')
        aliases = []
        if by_id_root.exists():
            for alias in sorted(by_id_root.iterdir()):
                try:
                    if alias.resolve() == device_path.resolve():
                        aliases.append(str(alias))
                except Exception:
                    continue
        if aliases:
            info['aliases'] = aliases
        if self.v4l2_ctl:
            try:
                proc = subprocess.run(
                    [self.v4l2_ctl, '--device', str(device_path), '--all'],
                    capture_output=True,
                    text=True,
                    timeout=15,
                )
                info['details'] = (proc.stdout or proc.stderr or '').strip()
                info['available'] = proc.returncode == 0
            except Exception as e:
                info['available'] = False
                info['probe_error'] = str(e)
        else:
            probe = self._ffmpeg_probe(str(device_path))
            info['available'] = probe['success']
            if probe.get('output'):
                info['details'] = probe['output']
        return info

    def capability_info(self) -> Dict[str, Any]:
        devices = self._list_device_paths()
        return {
            'platform': 'linux',
            'backend': 'v4l2-ffmpeg',
            'available': bool(devices),
            'device_count': len(devices),
            'supports_frame_capture': True,
            'supports_video_capture': True,
            'ffmpeg': bool(self.ffmpeg),
            'ffprobe': bool(self.ffprobe),
            'v4l2_ctl': bool(self.v4l2_ctl),
            'devices': [str(p) for p in devices],
        }

    def list_cameras(self) -> Dict[str, Any]:
        devices = [self._device_info(path) for path in self._list_device_paths()]
        return {
            'success': True,
            'backend': 'v4l2-ffmpeg',
            'camera_count': len(devices),
            'cameras': devices,
        }

    def get_camera_status(self) -> Dict[str, Any]:
        devices = self.list_cameras()
        payload = {
            'success': True,
            'backend': 'v4l2-ffmpeg',
            'ffmpeg': bool(self.ffmpeg),
            'ffprobe': bool(self.ffprobe),
            'v4l2_ctl': bool(self.v4l2_ctl),
            'camera_count': devices.get('camera_count', 0),
            'cameras': devices.get('cameras', []),
        }
        if payload['camera_count'] == 0:
            payload['available'] = False
            payload['reason'] = 'No /dev/video* devices found'
        else:
            payload['available'] = True
        return payload

    def _pick_device(self, params: Dict[str, Any]) -> str:
        device = params.get('device') or params.get('device_path')
        if device:
            return str(Path(str(device)).expanduser())
        devices = self._list_device_paths()
        if not devices:
            raise PlatformError('No /dev/video* devices found')
        return str(devices[0])

    def capture_frame(self, params: Dict[str, Any]) -> Dict[str, Any]:
        device = self._pick_device(params)
        fmt = str(params.get('format', 'png')).lower()
        if fmt not in ('png', 'jpg', 'jpeg', 'bmp'):
            return {'success': False, 'error': f'Unsupported frame format: {fmt}'}
        suffix = '.jpg' if fmt == 'jpeg' else f'.{fmt}'
        path = self._expand_output_path(params.get('output_path') or params.get('path'), suffix)
        width = params.get('width')
        height = params.get('height')
        cmd = [self.ffmpeg, '-y', '-v', 'error', '-f', 'v4l2']
        if width and height:
            cmd += ['-video_size', f'{int(width)}x{int(height)}']
        cmd += ['-i', device, '-frames:v', '1', path]
        try:
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        except Exception as e:
            return {'success': False, 'error': str(e)}
        if proc.returncode != 0:
            return {'success': False, 'error': (proc.stderr or proc.stdout or 'frame capture failed').strip()}
        payload = {
            'success': True,
            'device': device,
            'format': fmt,
        }
        payload.update(self._encode_file(path))
        return payload

    def capture_video(self, params: Dict[str, Any]) -> Dict[str, Any]:
        device = self._pick_device(params)
        duration = max(1, min(int(params.get('duration', 5)), 600))
        fmt = str(params.get('format', 'mp4')).lower()
        extension = 'mkv' if fmt == 'matroska' else fmt
        if extension not in ('mp4', 'mkv', 'webm'):
            return {'success': False, 'error': f'Unsupported video format: {fmt}'}
        path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{extension}')
        width = params.get('width')
        height = params.get('height')
        fps = params.get('fps')
        cmd = [self.ffmpeg, '-y', '-v', 'error', '-f', 'v4l2']
        if fps:
            cmd += ['-framerate', str(fps)]
        if width and height:
            cmd += ['-video_size', f'{int(width)}x{int(height)}']
        cmd += ['-i', device, '-t', str(duration), path]
        try:
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30)
        except Exception as e:
            return {'success': False, 'error': str(e)}
        if proc.returncode != 0:
            return {'success': False, 'error': (proc.stderr or proc.stdout or 'video capture failed').strip()}
        payload = {
            'success': True,
            'device': device,
            'format': extension,
            'duration': duration,
        }
        payload.update(self._encode_file(path))
        media_duration = self._media_duration(path)
        if media_duration is not None:
            payload['measured_duration'] = media_duration
        return payload


def make_camera_controller(config: Dict[str, Any]) -> Optional[CameraControllerBase]:
    if not config.get('enable_camera_control'):
        return None
    if is_linux():
        try:
            controller = PosixCameraController()
            if not controller._list_device_paths():
                _log_disabled('camera_control', 'no /dev/video* devices found')
                return None
            return controller
        except Exception as e:
            _log_disabled('camera_control', str(e))
            return None
    _log_disabled('camera_control', f'unsupported platform: {sys.platform}')
    return None


class NodeAgent:
    def __init__(self, config_path: Optional[str] = None):
        self.config = self._load_config(config_path)
        self.executor = make_executor(self.config)
        self.computer = make_computer_controller(self.config)
        self.audio = make_audio_controller(self.config)
        self.camera = make_camera_controller(self.config)
        self.browser = None
        if self.config.get('enable_browser') and HAS_BROWSER:
            try:
                self.browser = BrowserController()
            except Exception as e:
                logger.warning(f"BrowserController init failed: {e}")
        self.capabilities = self._detect_capabilities()

    def _load_config(self, path: Optional[str]) -> Dict[str, Any]:
        """Load node configuration from JSON."""
        cfg_path = Path(path).expanduser() if path else Path.home() / '.config' / 'hermes-node' / 'config.json'
        if not cfg_path.exists():
            logger.error(f"Config not found: {cfg_path}")
            logger.error(f"Run the installer or copy config-template.json to {cfg_path}")
            sys.exit(1)
        try:
            with open(cfg_path) as f:
                data = json.load(f)
        except json.JSONDecodeError as e:
            logger.error(f"Config file is not valid JSON: {e}")
            logger.error(f"File: {cfg_path}")
            sys.exit(1)
        # Merge defaults
        merged = {**DEFAULT_CONFIG, **data}
        if not merged['token']:
            logger.error("Token missing from config")
            sys.exit(1)
        return merged

    def _detect_capabilities(self) -> Dict[str, Any]:
        """Detect which optional tools are available on this machine."""
        caps = {
            'enable_browser': self.config.get('enable_browser', False),
            'enable_computer_control': self.config.get('enable_computer_control', False),
            'enable_desktop_observe': self.config.get('enable_desktop_observe', False),
            'enable_audio_control': self.config.get('enable_audio_control', False),
            'enable_camera_control': self.config.get('enable_camera_control', False),
        }
        if self.browser is not None:
            caps['browser_control'] = {'available': True}
        if self.computer is not None:
            cc_info = {
                'display': os.environ.get('DISPLAY', ':0'),
                'has_xdotool': subprocess.run(['which', 'xdotool'], capture_output=True).returncode == 0,
                'has_import': subprocess.run(['which', 'import'], capture_output=True).returncode == 0,
                'has_scrot': subprocess.run(['which', 'scrot'], capture_output=True).returncode == 0,
            }
            caps['computer_control'] = cc_info
        if caps['enable_desktop_observe']:
            caps['desktop_observe'] = {
                'available': self.computer is not None,
                'display': os.environ.get('DISPLAY', ':0')
            }
        if caps['enable_audio_control']:
            if self.audio is not None:
                caps['audio_control'] = self.audio.capability_info()
            else:
                caps['audio_control'] = {
                    'available': False,
                    'reason': 'audio control requested but backend dependencies are unavailable'
                }
        if caps['enable_camera_control']:
            if self.camera is not None:
                caps['camera_control'] = self.camera.capability_info()
            else:
                caps['camera_control'] = {
                    'available': False,
                    'reason': 'camera control requested but no supported camera backend/devices are available'
                }
        return caps

    async def connect_and_run(self):
        """Main loop: connect to gateway and process commands."""
        url = f"{self.config['gateway_url']}?node_name={self.config['node_name']}&token={self.config['token']}"
        _log_connect(url)

        ssl_context = None
        if url.startswith('wss://'):
            cert_path = self.config.get('gateway_cert_path')
            if cert_path:
                ssl_context = ssl.create_default_context(cafile=str(Path(cert_path).expanduser()))
            else:
                ssl_context = ssl.create_default_context()
                ssl_context.check_hostname = False
                ssl_context.verify_mode = ssl.CERT_NONE
                _log_tls_disabled()
        while True:
            try:
                async with websockets.connect(url, ping_interval=20, ping_timeout=10, ssl=ssl_context) as ws:
                    _log_connected()

                    # Send registration frame expected by the gateway
                    _log_registering(self.config['node_name'])
                    await ws.send(json.dumps({
                        "type": "register",
                        "node_name": self.config['node_name'],
                        "version": "1.0",
                        "tools": self._get_available_tools(),
                        "capabilities": self.capabilities
                    }))
                    _log_waiting()

                    heartbeat_task = asyncio.create_task(self._heartbeat_loop(ws))
                    disconnect_reason = None
                    try:
                        async for raw in ws:
                            msg = json.loads(raw)
                            await self._handle_message(ws, msg)
                    except Exception as e:
                        disconnect_reason = e
                        raise
                    finally:
                        heartbeat_task.cancel()
                        try:
                            await heartbeat_task
                        except asyncio.CancelledError:
                            pass
                        _log_disconnected(disconnect_reason)

            except Exception as e:
                _log_connection_error(e)
                _log_reconnect(self.config['reconnect_interval'], e)
                await asyncio.sleep(self.config['reconnect_interval'])

    def _get_available_tools(self) -> list:
        """Return list of capability strings for gateway registration."""
        tools = ['exec']
        if self.browser is not None:
            tools.append('browser_control')
        if self.computer is not None:
            tools.append('computer_control')
        if self.config.get('enable_desktop_observe') and self.computer is not None:
            tools.append('desktop_observe')
        if self.config.get('enable_audio_control') and self.audio is not None:
            tools.append('audio_control')
        if self.config.get('enable_camera_control') and self.camera is not None:
            tools.append('camera_control')
        return tools

    async def _handle_message(self, ws, msg: Dict[str, Any]):
        req_type = msg.get('type')
        if req_type == 'exec':
            cmd_id = msg['id']
            command = msg['command']
            await self._handle_exec(ws, cmd_id, command, msg.get('approved', False))
        elif req_type == 'computer_control':
            action = msg['action']
            params = msg.get('params', {})
            await self._handle_cc(ws, msg.get('id'), action, params)
        elif req_type == 'desktop_observe':
            action = msg['action']
            params = msg.get('params', {})
            await self._handle_desktop_observe(ws, msg.get('id'), action, params)
        elif req_type == 'browser_control':
            command = msg.get('command')
            params = msg.get('params', {})
            await self._handle_browser_control(ws, msg.get('id'), command, params)
        elif req_type == 'audio_control':
            action = msg['action']
            params = msg.get('params', {})
            await self._handle_audio_control(ws, msg.get('id'), action, params)
        elif req_type == 'camera_control':
            action = msg['action']
            params = msg.get('params', {})
            await self._handle_camera_control(ws, msg.get('id'), action, params)
        elif req_type == 'register_ack':
            _log_registration_ack()
            _log_registered(self.config['node_name'])
        elif req_type == 'heartbeat_ack':
            _log_heartbeat_ack()
            return
        else:
            _log_unknown_message(req_type)

    async def _heartbeat_loop(self, ws):
        """Send periodic heartbeats so the gateway can track liveness."""
        interval = max(5, int(self.config.get('heartbeat_interval', 30)))
        try:
            while True:
                await asyncio.sleep(interval)
                await ws.send(json.dumps({
                    'type': 'heartbeat',
                    'node_name': self.config['node_name'],
                    'timestamp': time.time(),
                }))
        except asyncio.CancelledError:
            raise
        except Exception as e:
            _log_connection_error(f"heartbeat loop stopped: {e}")

    async def _send_json(self, ws, payload: Dict[str, Any]):
        await ws.send(json.dumps(payload))

    async def _handle_exec(self, ws, cmd_id: str, command: str, approved: bool = False):
        """Execute a shell command via the configured executor.

        Uses the gateway's current exec protocol:
        - optional streamed chunks via ``exec_output``
        - terminal result via ``exec_complete``
        """
        _log_exec_received(command)
        try:
            result = self.executor.execute(command, approved=approved)
            stdout = result.get('stdout', '') or ''
            stderr = result.get('stderr', '') or ''
            if stdout:
                await self._send_json(ws, {
                    'type': 'exec_output',
                    'id': cmd_id,
                    'stream': 'stdout',
                    'data': stdout,
                })
            if stderr:
                await self._send_json(ws, {
                    'type': 'exec_output',
                    'id': cmd_id,
                    'stream': 'stderr',
                    'data': stderr,
                })
            exit_code = result.get('exit_code', -1)
            error = result.get('error')
            if error:
                _log_exec_failed(command, error, exit_code)
            else:
                _log_exec_completed(command, exit_code)
            await self._send_json(ws, {
                'type': 'exec_complete',
                'id': cmd_id,
                'exit_code': exit_code,
                'error': error,
            })
        except Exception as e:
            _log_exec_failed(command, str(e), -1)
            await self._send_json(ws, {
                'type': 'exec_complete',
                'id': cmd_id,
                'exit_code': -1,
                'error': str(e),
            })

    async def _handle_cc(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
        _log_cc_received(action)
        if self.computer is None:
            result = {
                'success': False,
                'error': 'computer_control not available on this node',
            }
        else:
            try:
                if action == 'screenshot':
                    result = self.computer.screenshot(params.get('output_path'))
                elif action == 'mouse_move':
                    result = self.computer.mouse_move(int(params['x']), int(params['y']))
                elif action == 'mouse_click':
                    result = self.computer.mouse_click(int(params.get('button', 1)))
                elif action == 'mouse_position':
                    result = self.computer.mouse_position()
                elif action == 'type_text':
                    result = self.computer.type_text(params['text'])
                elif action == 'key_press':
                    result = self.computer.key_press(params['key'])
                elif action == 'get_active_window':
                    result = self.computer.get_active_window()
                else:
                    result = {'success': False, 'error': f'Unknown computer_control action: {action}'}
            except Exception as e:
                result = {'success': False, 'error': str(e)}
        _log_tool_completed('computer', action, bool(result.get('success')), result.get('error'))
        payload = {'type': 'computer_control_result', 'id': cmd_id, 'action': action}
        payload.update(result)
        await self._send_json(ws, payload)

    async def _handle_desktop_observe(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
        logger.info(f"observe ▶ {_preview_command(action)}")
        if self.computer is None:
            result = {
                'success': False,
                'error': 'desktop_observe requires computer_control support on this node',
            }
        else:
            try:
                if action in ('active_window', 'get_active_window'):
                    result = self.computer.get_active_window()
                elif action == 'mouse_position':
                    result = self.computer.mouse_position()
                elif action == 'screenshot':
                    result = self.computer.screenshot(params.get('output_path'))
                else:
                    result = {'success': False, 'error': f'Unknown desktop_observe action: {action}'}
            except Exception as e:
                result = {'success': False, 'error': str(e)}
        _log_tool_completed('observe', action, bool(result.get('success')), result.get('error'))
        payload = {'type': 'desktop_observe_result', 'id': cmd_id, 'action': action}
        payload.update(result)
        await self._send_json(ws, payload)

    async def _handle_browser_control(self, ws, cmd_id: str, command: str, params: Dict[str, Any]):
        if self.browser is None:
            await self._send_json(ws, {
                'type': 'browser_control_result',
                'id': cmd_id,
                'command': command,
                'success': False,
                'error': 'browser_control not available on this node',
            })
            return
        _log_browser_received(command)
        try:
            if hasattr(self.browser, 'execute'):
                result = self.browser.execute(command, params)
            elif hasattr(self.browser, 'run'):
                result = self.browser.run(command, params)
            else:
                result = {'success': False, 'error': 'BrowserController has no execute/run entrypoint'}
        except Exception as e:
            result = {'success': False, 'error': str(e)}
        _log_tool_completed('browser', command, bool(result.get('success')), result.get('error'))
        payload = {'type': 'browser_control_result', 'id': cmd_id, 'command': command}
        payload.update(result)
        await self._send_json(ws, payload)

    async def _handle_audio_control(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
        _log_audio_received(action)
        if self.audio is None:
            await self._send_json(ws, {
                'type': 'audio_control_result',
                'id': cmd_id,
                'action': action,
                'success': False,
                'error': 'audio_control not available on this node',
            })
            return
        try:
            if action == 'list_audio_devices':
                result = self.audio.list_audio_devices()
            elif action == 'get_audio_status':
                result = self.audio.get_audio_status()
            elif action == 'capture_output':
                result = self.audio.capture_output(params)
            elif action == 'capture_input':
                result = self.audio.capture_input(params)
            elif action == 'play_audio':
                result = self.audio.play_audio(params)
            else:
                result = {'success': False, 'error': f'Unknown audio_control action: {action}'}
        except Exception as e:
            result = {'success': False, 'error': str(e)}
        _log_tool_completed('audio', action, bool(result.get('success')), result.get('error'))
        payload = {'type': 'audio_control_result', 'id': cmd_id, 'action': action}
        payload.update(result)
        await self._send_json(ws, payload)

    async def _handle_camera_control(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
        _log_camera_received(action)
        if self.camera is None:
            await self._send_json(ws, {
                'type': 'camera_control_result',
                'id': cmd_id,
                'action': action,
                'success': False,
                'error': 'camera_control not available on this node',
            })
            return
        try:
            if action == 'list_cameras':
                result = self.camera.list_cameras()
            elif action == 'get_camera_status':
                result = self.camera.get_camera_status()
            elif action == 'capture_frame':
                result = self.camera.capture_frame(params)
            elif action == 'capture_video':
                result = self.camera.capture_video(params)
            else:
                result = {'success': False, 'error': f'Unknown camera_control action: {action}'}
        except Exception as e:
            result = {'success': False, 'error': str(e)}
        _log_tool_completed('camera', action, bool(result.get('success')), result.get('error'))
        payload = {'type': 'camera_control_result', 'id': cmd_id, 'action': action}
        payload.update(result)
        await self._send_json(ws, payload)


def main():
    parser = argparse.ArgumentParser(description="Hermes Node Agent")
    parser.add_argument('--config', type=str, help='Path to config JSON')
    parser.add_argument('--debug', action='store_true', help='Debug logging')
    args = parser.parse_args()

    if args.debug:
        logging.getLogger().setLevel(logging.DEBUG)

    # Load config to check token
    config = NodeAgent(args.config)._load_config(args.config)
    if config['token'] == DEFAULT_GATEWAY_TOKEN or config['token'] == 'GATEWAY_TOKEN_MUST_BE_PROVIDED':
        logger.error("ERROR: Token not set in config. Edit ~/.config/hermes-node/config.json")
        sys.exit(1)

    agent = NodeAgent(args.config)
    _log_start(config['node_name'], agent._get_available_tools())
    try:
        asyncio.run(agent.connect_and_run())
    except KeyboardInterrupt:
        logger.info("Shutting down")

if __name__ == '__main__':
    main()
