v2.0.0: tool-based architecture (no LLM on client)

This commit is contained in:
David Piccinini 2026-05-16 16:22:11 -03:00
commit 87d5db0c25
4 changed files with 794 additions and 0 deletions

View File

@ -0,0 +1,2 @@
"""EasyBot Windows Agent — tool-based remote PC automation client."""
__version__ = "2.0.0"

View File

@ -0,0 +1,566 @@
"""
EasyBot Windows Agent tool-based remote automation client.
The PC exposes granular tools (run_command, get_ui_tree, click, type, etc.)
and the EasyBot LLM agent controls everything directly. No LLM on client side.
Configuration via environment variables:
EASYBOT_SERVER WebSocket URL (e.g., wss://app.example.com/ws/windows-agent)
EASYBOT_TOKEN Authentication token (eb_wag_...)
LOG_LEVEL DEBUG | INFO | WARNING (default: INFO)
"""
import asyncio
import base64
import io
import json
import logging
import os
import platform
import signal
import subprocess
import sys
import time
from typing import Any
import websockets
from websockets.client import WebSocketClientProtocol
from . import __version__
logger = logging.getLogger("easybot-windows-agent")
# ── Configuration ────────────────────────────────────────────────────────────
RECONNECT_DELAY_BASE = 5
RECONNECT_DELAY_MAX = 120
TOOL_TIMEOUT = 60 # max seconds for a single tool call
# ── Tool Handlers ────────────────────────────────────────────────────────────
async def tool_run_command(args: dict[str, Any]) -> dict[str, Any]:
"""Execute a shell command and return output."""
command = args.get("command", "")
shell = args.get("shell", "powershell")
if not command:
return {"error": "No command provided"}
try:
if shell == "cmd":
proc = await asyncio.create_subprocess_exec(
"cmd", "/c", command,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
else:
proc = await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-NonInteractive", "-Command", command,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TOOL_TIMEOUT)
return {
"stdout": stdout.decode("utf-8", errors="replace").strip(),
"stderr": stderr.decode("utf-8", errors="replace").strip(),
"exit_code": proc.returncode,
}
except asyncio.TimeoutError:
return {"error": "Command timed out", "exit_code": -1}
except Exception as e:
return {"error": str(e)}
async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]:
"""Get the accessibility tree of a window or the desktop."""
import uiautomation as auto
window_title = args.get("window")
max_depth = args.get("depth", 3)
try:
if window_title:
root = auto.WindowControl(searchDepth=1, Name=window_title)
if not root.Exists(maxSearchSeconds=3):
# Try substring match
root = auto.WindowControl(searchDepth=1, SubName=window_title)
if not root.Exists(maxSearchSeconds=2):
return {"error": f"Window '{window_title}' not found"}
else:
root = auto.GetForegroundControl()
if not root:
root = auto.GetRootControl()
def walk(ctrl: Any, depth: int, max_d: int) -> list[dict]:
if depth > max_d:
return []
items = []
try:
name = ctrl.Name or ""
ctrl_type = ctrl.ControlTypeName or ""
auto_id = ctrl.AutomationId or ""
rect = ctrl.BoundingRectangle
value = ""
try:
value = ctrl.GetValuePattern().Value if ctrl.GetValuePattern() else ""
except Exception:
pass
item: dict[str, Any] = {
"type": ctrl_type,
"name": name,
}
if auto_id:
item["automation_id"] = auto_id
if value:
item["value"] = value
if rect and rect.width() > 0:
item["rect"] = {"x": rect.left, "y": rect.top, "w": rect.width(), "h": rect.height()}
children = []
if depth < max_d:
for child in ctrl.GetChildren():
children.extend(walk(child, depth + 1, max_d))
if children:
item["children"] = children
items.append(item)
except Exception:
pass
return items
tree = walk(root, 0, max_depth)
return {"window": root.Name or "Desktop", "tree": tree}
except Exception as e:
return {"error": str(e)}
async def tool_list_windows(args: dict[str, Any]) -> dict[str, Any]:
"""List all open top-level windows."""
import uiautomation as auto
try:
windows = []
for win in auto.GetRootControl().GetChildren():
try:
if win.ControlTypeName == "WindowControl" or win.ClassName == "Window":
name = win.Name or ""
if name and name != "Program Manager":
windows.append({
"title": name,
"class": win.ClassName or "",
"process_id": win.ProcessId,
})
except Exception:
continue
return {"windows": windows}
except Exception as e:
return {"error": str(e)}
async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]:
"""Click a UI element by name, automation_id, or coordinates."""
name = args.get("name")
auto_id = args.get("automation_id")
x = args.get("x")
y = args.get("y")
button = args.get("button", "left")
double = args.get("double", False)
try:
if x is not None and y is not None:
import pyautogui
if double:
pyautogui.doubleClick(x, y)
elif button == "right":
pyautogui.rightClick(x, y)
else:
pyautogui.click(x, y)
return {"success": True, "method": "coordinates", "x": x, "y": y}
import uiautomation as auto
ctrl = None
if auto_id:
ctrl = auto.Control(searchDepth=8, AutomationId=auto_id)
elif name:
ctrl = auto.Control(searchDepth=8, Name=name)
if ctrl and ctrl.Exists(maxSearchSeconds=3):
if double:
ctrl.DoubleClick()
else:
ctrl.Click()
return {"success": True, "method": "ui_automation", "element": ctrl.Name}
else:
return {"error": f"Element not found: name={name}, automation_id={auto_id}"}
except Exception as e:
return {"error": str(e)}
async def tool_type_text(args: dict[str, Any]) -> dict[str, Any]:
"""Type text, optionally into a specific element."""
text = args.get("text", "")
element_name = args.get("element_name")
if not text:
return {"error": "No text provided"}
try:
if element_name:
import uiautomation as auto
ctrl = auto.Control(searchDepth=8, Name=element_name)
if ctrl.Exists(maxSearchSeconds=3):
try:
ctrl.GetValuePattern().SetValue(text)
return {"success": True, "method": "set_value", "element": element_name}
except Exception:
ctrl.Click()
await asyncio.sleep(0.2)
import pyautogui
pyautogui.typewrite(text, interval=0.02) if text.isascii() else pyautogui.write(text)
return {"success": True, "method": "keyboard"}
except Exception as e:
return {"error": str(e)}
async def tool_press_keys(args: dict[str, Any]) -> dict[str, Any]:
"""Press keyboard keys/shortcuts (e.g. 'ctrl+s', 'enter', 'alt+f4')."""
keys = args.get("keys", "")
if not keys:
return {"error": "No keys provided"}
try:
import pyautogui
parts = [k.strip() for k in keys.lower().split("+")]
if len(parts) > 1:
pyautogui.hotkey(*parts)
else:
pyautogui.press(parts[0])
return {"success": True, "keys": keys}
except Exception as e:
return {"error": str(e)}
async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]:
"""Take a screenshot and return as base64 PNG."""
from PIL import ImageGrab
try:
region = args.get("region")
if region:
bbox = (region["x"], region["y"], region["x"] + region["w"], region["y"] + region["h"])
img = ImageGrab.grab(bbox=bbox)
else:
img = ImageGrab.grab()
# Resize for efficiency (max 1280px wide)
if img.width > 1280:
ratio = 1280 / img.width
img = img.resize((1280, int(img.height * ratio)))
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return {"image_base64": b64, "width": img.width, "height": img.height}
except Exception as e:
return {"error": str(e)}
async def tool_read_clipboard(args: dict[str, Any]) -> dict[str, Any]:
"""Read text from the clipboard."""
try:
import pyperclip
text = pyperclip.paste()
return {"text": text}
except ImportError:
# Fallback via PowerShell
proc = await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-Command", "Get-Clipboard",
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return {"text": stdout.decode("utf-8", errors="replace").strip()}
except Exception as e:
return {"error": str(e)}
async def tool_set_clipboard(args: dict[str, Any]) -> dict[str, Any]:
"""Set clipboard text content."""
text = args.get("text", "")
try:
import pyperclip
pyperclip.copy(text)
return {"success": True}
except ImportError:
proc = await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-Command", f"Set-Clipboard -Value '{text}'",
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
await proc.communicate()
return {"success": True}
except Exception as e:
return {"error": str(e)}
async def tool_focus_window(args: dict[str, Any]) -> dict[str, Any]:
"""Bring a window to the foreground."""
title = args.get("title", "")
if not title:
return {"error": "No window title provided"}
try:
import uiautomation as auto
win = auto.WindowControl(searchDepth=1, SubName=title)
if win.Exists(maxSearchSeconds=3):
win.SetFocus()
win.SetActive()
return {"success": True, "window": win.Name}
else:
return {"error": f"Window '{title}' not found"}
except Exception as e:
return {"error": str(e)}
async def tool_file_read(args: dict[str, Any]) -> dict[str, Any]:
"""Read a text file."""
path = args.get("path", "")
if not path:
return {"error": "No path provided"}
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read(100_000) # limit to 100KB
return {"content": content, "size": os.path.getsize(path)}
except Exception as e:
return {"error": str(e)}
async def tool_file_write(args: dict[str, Any]) -> dict[str, Any]:
"""Write content to a text file."""
path = args.get("path", "")
content = args.get("content", "")
if not path:
return {"error": "No path provided"}
try:
os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return {"success": True, "bytes_written": len(content.encode("utf-8"))}
except Exception as e:
return {"error": str(e)}
# ── Tool Registry ────────────────────────────────────────────────────────────
TOOLS: dict[str, Any] = {
"run_command": tool_run_command,
"get_ui_tree": tool_get_ui_tree,
"list_windows": tool_list_windows,
"click_element": tool_click_element,
"type_text": tool_type_text,
"press_keys": tool_press_keys,
"screenshot": tool_screenshot,
"read_clipboard": tool_read_clipboard,
"set_clipboard": tool_set_clipboard,
"focus_window": tool_focus_window,
"file_read": tool_file_read,
"file_write": tool_file_write,
}
# ── Agent Client ─────────────────────────────────────────────────────────────
class AgentClient:
def __init__(self, server_url: str, token: str):
self.server_url = server_url
self.token = token
self.ws: WebSocketClientProtocol | None = None
self.running = True
self.current_call_id: str | None = None
self.cancel_event = asyncio.Event()
self._reconnect_delay = RECONNECT_DELAY_BASE
async def run(self) -> None:
"""Main loop: connect → handle messages → reconnect on failure."""
while self.running:
try:
await self._connect_and_handle()
except (websockets.ConnectionClosed, OSError, ConnectionRefusedError) as e:
logger.warning("Connection lost: %s — reconnecting in %ds", e, self._reconnect_delay)
except Exception:
logger.exception("Unexpected error — reconnecting in %ds", self._reconnect_delay)
if not self.running:
break
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(self._reconnect_delay * 2, RECONNECT_DELAY_MAX)
async def _connect_and_handle(self) -> None:
"""Single connection lifecycle."""
extra_headers = {"Authorization": f"Bearer {self.token}"}
async with websockets.connect(
self.server_url,
additional_headers=extra_headers,
ping_interval=None,
max_size=16 * 1024 * 1024,
close_timeout=10,
) as ws:
self.ws = ws
self._reconnect_delay = RECONNECT_DELAY_BASE
logger.info("Connected to %s", self.server_url)
# Send client info with list of available tools
await self._send({
"type": "client_info",
"hostname": platform.node(),
"os_version": f"{platform.system()} {platform.version()}",
"python_version": platform.python_version(),
"client_version": __version__,
"tools": list(TOOLS.keys()),
})
try:
async for raw in ws:
msg = json.loads(raw)
await self._handle_message(msg)
finally:
self.ws = None
self.current_call_id = None
async def _handle_message(self, msg: dict[str, Any]) -> None:
"""Dispatch incoming messages from the server."""
msg_type = msg.get("type")
if msg_type == "ping":
await self._send({"type": "pong"})
elif msg_type == "tool_call":
call_id = msg["call_id"]
tool_name = msg["tool_name"]
arguments = msg.get("arguments", {})
logger.info("Tool call: %s (call_id=%s)", tool_name, call_id[:8])
asyncio.create_task(self._execute_tool(call_id, tool_name, arguments))
elif msg_type == "cancel":
call_id = msg.get("call_id")
if self.current_call_id == call_id:
logger.info("Cancel requested for %s", call_id)
self.cancel_event.set()
else:
logger.debug("Unknown message type: %s", msg_type)
async def _execute_tool(self, call_id: str, tool_name: str, arguments: dict[str, Any]) -> None:
"""Execute a tool and return the result."""
self.current_call_id = call_id
self.cancel_event.clear()
start = time.time()
try:
handler = TOOLS.get(tool_name)
if not handler:
await self._send({
"type": "tool_result",
"call_id": call_id,
"error": f"Unknown tool: {tool_name}",
"duration_ms": 0,
})
return
result = await asyncio.wait_for(handler(arguments), timeout=TOOL_TIMEOUT)
duration_ms = int((time.time() - start) * 1000)
if "error" in result:
await self._send({
"type": "tool_result",
"call_id": call_id,
"error": result["error"],
"duration_ms": duration_ms,
})
else:
await self._send({
"type": "tool_result",
"call_id": call_id,
"result": result,
"duration_ms": duration_ms,
})
logger.info("Tool %s completed in %dms", tool_name, duration_ms)
except asyncio.TimeoutError:
await self._send({
"type": "tool_result",
"call_id": call_id,
"error": f"Tool '{tool_name}' timed out after {TOOL_TIMEOUT}s",
"duration_ms": int((time.time() - start) * 1000),
})
except Exception as e:
logger.error("Tool %s failed: %s", tool_name, e)
await self._send({
"type": "tool_result",
"call_id": call_id,
"error": f"{type(e).__name__}: {e}",
"duration_ms": int((time.time() - start) * 1000),
})
finally:
self.current_call_id = None
async def _send(self, msg: dict[str, Any]) -> None:
"""Send a JSON message to the server."""
if self.ws:
try:
await self.ws.send(json.dumps(msg))
except (websockets.ConnectionClosed, Exception):
pass
def stop(self) -> None:
"""Signal the client to stop."""
self.running = False
self.cancel_event.set()
# ── Entry Point ──────────────────────────────────────────────────────────────
def main() -> None:
server_url = os.environ.get("EASYBOT_SERVER")
token = os.environ.get("EASYBOT_TOKEN")
if not server_url or not token:
print("Error: EASYBOT_SERVER and EASYBOT_TOKEN environment variables are required.")
print()
print("Usage:")
print(" set EASYBOT_SERVER=wss://app.example.com/ws/windows-agent")
print(" set EASYBOT_TOKEN=eb_wag_...")
print(" easybot-windows-agent")
sys.exit(1)
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, log_level, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger.info("EasyBot Windows Agent v%s starting", __version__)
logger.info("Server: %s", server_url)
logger.info("Token: %s...", token[:12] if len(token) > 12 else "***")
logger.info("Available tools: %s", ", ".join(TOOLS.keys()))
client = AgentClient(server_url, token)
def handle_signal(*_: Any) -> None:
logger.info("Shutdown signal received")
client.stop()
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
asyncio.run(client.run())
logger.info("Agent stopped")
if __name__ == "__main__":
main()

207
install.ps1 Normal file
View File

@ -0,0 +1,207 @@
# ============================================================================
# EasyBot Windows Agent — One-Line Installer
# ============================================================================
#
# Usage:
# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/pyptechnologies/easybot-windows-agent/main/install.ps1 | iex"
#
# Then configure:
# $env:EASYBOT_SERVER = "wss://your-server.com/ws/windows-agent"
# $env:EASYBOT_TOKEN = "eb_wag_..."
# easybot-windows-agent
#
# ============================================================================
$ErrorActionPreference = "Stop"
$InstallDir = "$env:ProgramData\EasyBot\WindowsAgent"
$ServiceName = "EasyBotWindowsAgent"
$PythonMinVersion = [version]"3.11.0"
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " EasyBot Windows Agent Installer v1.0" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ── Check admin ──────────────────────────────────────────────────────────────
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "[!] This installer requires administrator privileges." -ForegroundColor Yellow
Write-Host " Please run PowerShell as Administrator and try again." -ForegroundColor Yellow
exit 1
}
# ── Check Python ─────────────────────────────────────────────────────────────
Write-Host "[1/5] Checking Python..." -ForegroundColor White
$pythonCmd = $null
foreach ($cmd in @("python", "python3", "py -3")) {
try {
$ver = & $cmd.Split()[0] $cmd.Split()[1..99] --version 2>&1
if ($ver -match "Python (\d+\.\d+\.\d+)") {
$pyVer = [version]$Matches[1]
if ($pyVer -ge $PythonMinVersion) {
$pythonCmd = $cmd
Write-Host " Found Python $pyVer" -ForegroundColor Green
break
}
}
} catch {}
}
if (-not $pythonCmd) {
Write-Host " Python >= 3.11 not found. Installing via winget..." -ForegroundColor Yellow
try {
winget install Python.Python.3.12 --accept-source-agreements --accept-package-agreements --silent
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
$pythonCmd = "python"
Write-Host " Python installed successfully" -ForegroundColor Green
} catch {
Write-Host "[X] Failed to install Python. Please install Python 3.11+ manually." -ForegroundColor Red
Write-Host " https://www.python.org/downloads/" -ForegroundColor Gray
exit 1
}
}
# ── Create install directory ─────────────────────────────────────────────────
Write-Host "[2/5] Creating install directory..." -ForegroundColor White
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Write-Host " $InstallDir" -ForegroundColor Gray
# ── Install Python package from Gitea ────────────────────────────────────────
Write-Host "[3/5] Installing easybot-windows-agent from repository..." -ForegroundColor White
$repoZip = "$env:TEMP\easybot-windows-agent.zip"
$repoExtract = "$env:TEMP\easybot-windows-agent"
$repoUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/archive/main.zip"
# Download and extract
if (Test-Path $repoExtract) { Remove-Item $repoExtract -Recurse -Force }
Write-Host " Downloading from repository..." -ForegroundColor Gray
Invoke-WebRequest -Uri $repoUrl -OutFile $repoZip -UseBasicParsing
Expand-Archive -Path $repoZip -DestinationPath $repoExtract -Force
# Find the extracted folder (Gitea names it easybot-windows-agent/)
$pkgDir = Get-ChildItem -Path $repoExtract -Directory | Select-Object -First 1
# Temporarily allow errors (pip writes warnings to stderr which PowerShell treats as errors)
$ErrorActionPreference = "Continue"
# pip install from local directory
Write-Host " Installing package..." -ForegroundColor Gray
& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install --force-reinstall --no-deps $pkgDir.FullName 2>&1 | ForEach-Object {
if ($_ -match "Successfully installed") { Write-Host " $_" -ForegroundColor Green }
}
# Install dependencies
Write-Host " Installing dependencies..." -ForegroundColor Gray
& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install "websockets>=12.0,<14" "uiautomation>=2.0" "pyautogui>=0.9" "pywinauto>=0.6" "Pillow>=10.0" 2>&1 | Out-Null
$ErrorActionPreference = "Stop"
# Cleanup
Remove-Item $repoZip -Force -ErrorAction SilentlyContinue
Remove-Item $repoExtract -Recurse -Force -ErrorAction SilentlyContinue
Write-Host " Package installed successfully" -ForegroundColor Green
# ── Configure environment ────────────────────────────────────────────────────
Write-Host "[4/5] Configuration..." -ForegroundColor White
$configFile = "$InstallDir\config.env"
if (Test-Path $configFile) {
Write-Host " Existing config found at $configFile" -ForegroundColor Gray
} else {
# Use env vars if already set, otherwise prompt
$server = if ($env:EASYBOT_SERVER) { $env:EASYBOT_SERVER } else { Read-Host " Enter EasyBot server URL (e.g., wss://app.example.com/ws/windows-agent)" }
$token = if ($env:EASYBOT_TOKEN) { $env:EASYBOT_TOKEN } else { Read-Host " Enter authentication token (eb_wag_...)" }
Write-Host " Server: $server" -ForegroundColor Gray
Write-Host " Token: $($token.Substring(0, [Math]::Min(12, $token.Length)))..." -ForegroundColor Gray
@"
EASYBOT_SERVER=$server
EASYBOT_TOKEN=$token
LOG_LEVEL=INFO
"@ | Set-Content -Path $configFile -Encoding UTF8
Write-Host " Config saved to $configFile" -ForegroundColor Green
}
# ── Install as Windows Service via nssm ──────────────────────────────────────
Write-Host "[5/5] Installing as Windows Service..." -ForegroundColor White
# Download nssm if not present
$nssmPath = "$InstallDir\nssm.exe"
if (-not (Test-Path $nssmPath)) {
Write-Host " Downloading nssm..." -ForegroundColor Gray
$nssmUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/raw/branch/main/bin/nssm.exe"
Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmPath -UseBasicParsing
}
# Find the easybot-windows-agent script
$agentScript = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import shutil; print(shutil.which('easybot-windows-agent') or '')" 2>$null
if (-not $agentScript) {
# Fallback: use python -m
$agentScript = ""
}
# Remove existing service if present (ignore errors if not installed)
$ErrorActionPreference = "Continue"
& $nssmPath stop $ServiceName 2>&1 | Out-Null
& $nssmPath remove $ServiceName confirm 2>&1 | Out-Null
$ErrorActionPreference = "Stop"
# Parse config.env for environment variables
$envVars = @()
Get-Content $configFile | ForEach-Object {
if ($_ -match "^([^#=]+)=(.*)$") {
$envVars += "$($Matches[1])=$($Matches[2])"
}
}
if ($agentScript) {
& $nssmPath install $ServiceName $agentScript
} else {
$pythonExe = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import sys; print(sys.executable)"
& $nssmPath install $ServiceName $pythonExe "-m" "easybot_windows_agent.main"
}
& $nssmPath set $ServiceName DisplayName "EasyBot Windows Agent"
& $nssmPath set $ServiceName Description "Remote Windows GUI automation agent for EasyBot"
& $nssmPath set $ServiceName AppDirectory $InstallDir
& $nssmPath set $ServiceName AppEnvironmentExtra ($envVars -join "`n")
& $nssmPath set $ServiceName AppStdout "$InstallDir\agent.log"
& $nssmPath set $ServiceName AppStderr "$InstallDir\agent.log"
& $nssmPath set $ServiceName AppRotateFiles 1
& $nssmPath set $ServiceName AppRotateBytes 10485760
& $nssmPath set $ServiceName Start SERVICE_AUTO_START
& $nssmPath set $ServiceName AppExit Default Restart
& $nssmPath set $ServiceName AppRestartDelay 5000
Write-Host " Service installed: $ServiceName" -ForegroundColor Green
# Start the service
& $nssmPath start $ServiceName | Out-Null
Write-Host " Service started!" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Installation Complete!" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host " Install dir: $InstallDir" -ForegroundColor Gray
Write-Host " Config file: $configFile" -ForegroundColor Gray
Write-Host " Log file: $InstallDir\agent.log" -ForegroundColor Gray
Write-Host " Service: $ServiceName" -ForegroundColor Gray
Write-Host ""
Write-Host " Commands:" -ForegroundColor White
Write-Host " nssm status $ServiceName # Check status" -ForegroundColor Gray
Write-Host " nssm restart $ServiceName # Restart agent" -ForegroundColor Gray
Write-Host " nssm stop $ServiceName # Stop agent" -ForegroundColor Gray
Write-Host " nssm remove $ServiceName # Uninstall" -ForegroundColor Gray
Write-Host ""

19
pyproject.toml Normal file
View File

@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "easybot-windows-agent"
version = "2.0.0"
description = "EasyBot Windows Agent — remote PC tools client (no LLM)"
requires-python = ">=3.11"
dependencies = [
"websockets>=12.0,<14",
"uiautomation>=2.0",
"pyautogui>=0.9",
"pywinauto>=0.6",
"Pillow>=10.0",
]
[project.scripts]
easybot-windows-agent = "easybot_windows_agent.main:main"