feat: OpenClaw WebSocket integration with workspace file preview

- Migrate OpenClaw from HTTP (port 8004) to WebSocket (port 18789)
- Add workspace file list and content preview handlers
- Add OpenClawStatus component with agent/skills view
- Add OpenClawView panel in trader interface
- Add Zustand store for OpenClaw state management
- Fix gateway logging noise (yfinance, websockets)
- Fix RunWorkspaceManager.get_agent_asset_dir attribute error
- Handle missing workspace files gracefully in preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 11:08:15 +08:00
parent 9bcc4221a4
commit 6ecc224427
20 changed files with 5691 additions and 6 deletions

View File

@@ -26,10 +26,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
from backend.core.scheduler import Scheduler
from backend.services import gateway_admin_handlers
from backend.services import gateway_cycle_support
from backend.services import gateway_openclaw_handlers
from backend.services import gateway_runtime_support
from backend.services import gateway_stock_handlers
from shared.client import NewsServiceClient
from shared.client import TradingServiceClient
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
logger = logging.getLogger(__name__)
EDITABLE_AGENT_WORKSPACE_FILES = {
@@ -92,6 +94,7 @@ class Gateway:
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._project_root = Path(__file__).resolve().parents[2]
self._technical_analyzer = StockTechnicalAnalyzer()
self._openclaw_ws: OpenClawWebSocketClient | None = None
async def start(self, host: str = "0.0.0.0", port: int = 8766):
"""Start gateway server with proper initialization order.
@@ -185,6 +188,20 @@ class Gateway:
# Give a brief moment for any existing clients to reconnect
await asyncio.sleep(0.1)
# Connect to OpenClaw Gateway (18789) via WebSocket
logger.info("Connecting to OpenClaw Gateway...")
try:
self._openclaw_ws = OpenClawWebSocketClient(
url=OPENCLAW_WS_URL,
client_name="gateway-client",
client_version="1.0.0",
)
await self._openclaw_ws.connect()
logger.info("OpenClaw Gateway WebSocket connected")
except Exception as e:
logger.warning("Failed to connect to OpenClaw Gateway: %s", e)
self._openclaw_ws = None
# ======================================================================
# PHASE 2: Start market data service
# Now frontend is connected, start pushing price updates
@@ -434,6 +451,54 @@ class Gateway:
await self._handle_get_stock_technical_indicators(websocket, data)
elif msg_type == "run_stock_enrich":
await self._handle_run_stock_enrich(websocket, data)
elif msg_type == "get_openclaw_status":
await self._handle_get_openclaw_status(websocket, data)
elif msg_type == "get_openclaw_sessions":
await self._handle_get_openclaw_sessions(websocket, data)
elif msg_type == "get_openclaw_session_detail":
await self._handle_get_openclaw_session_detail(websocket, data)
elif msg_type == "get_openclaw_session_history":
await self._handle_get_openclaw_session_history(websocket, data)
elif msg_type == "get_openclaw_cron":
await self._handle_get_openclaw_cron(websocket, data)
elif msg_type == "get_openclaw_approvals":
await self._handle_get_openclaw_approvals(websocket, data)
elif msg_type == "get_openclaw_agents":
await self._handle_get_openclaw_agents(websocket, data)
elif msg_type == "get_openclaw_agents_presence":
await self._handle_get_openclaw_agents_presence(websocket, data)
elif msg_type == "get_openclaw_skills":
await self._handle_get_openclaw_skills(websocket, data)
elif msg_type == "get_openclaw_models":
await self._handle_get_openclaw_models(websocket, data)
elif msg_type == "get_openclaw_hooks":
await gateway_openclaw_handlers.handle_get_openclaw_hooks(self, websocket, data)
elif msg_type == "get_openclaw_plugins":
await gateway_openclaw_handlers.handle_get_openclaw_plugins(self, websocket, data)
elif msg_type == "get_openclaw_secrets_audit":
await gateway_openclaw_handlers.handle_get_openclaw_secrets_audit(self, websocket, data)
elif msg_type == "get_openclaw_security_audit":
await gateway_openclaw_handlers.handle_get_openclaw_security_audit(self, websocket, data)
elif msg_type == "get_openclaw_daemon_status":
await gateway_openclaw_handlers.handle_get_openclaw_daemon_status(self, websocket, data)
elif msg_type == "get_openclaw_pairing":
await gateway_openclaw_handlers.handle_get_openclaw_pairing(self, websocket, data)
elif msg_type == "get_openclaw_qr":
await gateway_openclaw_handlers.handle_get_openclaw_qr(self, websocket, data)
elif msg_type == "get_openclaw_update_status":
await gateway_openclaw_handlers.handle_get_openclaw_update_status(self, websocket, data)
elif msg_type == "get_openclaw_models_aliases":
await gateway_openclaw_handlers.handle_get_openclaw_models_aliases(self, websocket, data)
elif msg_type == "get_openclaw_models_fallbacks":
await gateway_openclaw_handlers.handle_get_openclaw_models_fallbacks(self, websocket, data)
elif msg_type == "get_openclaw_models_image_fallbacks":
await gateway_openclaw_handlers.handle_get_openclaw_models_image_fallbacks(self, websocket, data)
elif msg_type == "get_openclaw_skill_update":
await gateway_openclaw_handlers.handle_get_openclaw_skill_update(self, websocket, data)
elif msg_type == "get_openclaw_workspace_files":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
elif msg_type == "get_openclaw_workspace_file":
await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data)
except websockets.ConnectionClosed:
pass
@@ -669,6 +734,83 @@ class Gateway:
) -> None:
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data)
async def _handle_get_openclaw_status(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_status(self, websocket, data)
async def _handle_get_openclaw_sessions(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_sessions(self, websocket, data)
async def _handle_get_openclaw_session_detail(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_session_detail(self, websocket, data)
async def _handle_get_openclaw_session_history(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_session_history(self, websocket, data)
async def _handle_get_openclaw_cron(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_cron(self, websocket, data)
async def _handle_get_openclaw_approvals(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_approvals(self, websocket, data)
async def _handle_get_openclaw_agents(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_agents(self, websocket, data)
async def _handle_get_openclaw_agents_presence(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_agents_presence(self, websocket, data)
async def _handle_get_openclaw_skills(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_skills(self, websocket, data)
async def _handle_get_openclaw_models(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_models(self, websocket, data)
async def _handle_get_openclaw_workspace_files(
self,
websocket: ServerConnection,
data: Dict[str, Any],
) -> None:
await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data)
@staticmethod
def _normalize_watchlist(raw_tickers: Any) -> List[str]:
return gateway_runtime_support.normalize_watchlist(raw_tickers)

View File

@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.services.gateway import Gateway
logger = logging.getLogger(__name__)
def _get_ws_client(gateway) -> "OpenClawWebSocketClient":
"""Get the OpenClaw WebSocket client from gateway."""
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient
client = gateway._openclaw_ws
if client is None:
raise RuntimeError("OpenClaw Gateway not connected")
return client
async def _ws_call(gateway, method: str, params: dict | None = None) -> dict:
"""Call OpenClaw Gateway via WebSocket and return result."""
try:
client = _get_ws_client(gateway)
return await client.call_method(method, params)
except Exception as exc:
logger.warning("OpenClaw Gateway call failed for %s: %s", method, exc)
return {"error": str(exc)[:200]}
async def handle_get_openclaw_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "status")
await websocket.send(json.dumps({"type": "openclaw_status_loaded", "data": result}))
async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "sessions.list", {"limit": 50, "includeLastMessage": True})
await websocket.send(json.dumps({"type": "openclaw_sessions_loaded", "data": result}))
async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
result = await _ws_call(gateway, "sessions.list", {"limit": 1, "agentId": session_key.split(":")[1] if session_key else None})
await websocket.send(json.dumps({
"type": "openclaw_session_detail_loaded",
"data": result,
"session_key": session_key,
}))
async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None:
session_key = data.get("session_key", "")
limit = data.get("limit", 20)
result = await _ws_call(gateway, "sessions.list", {"limit": limit})
await websocket.send(json.dumps({
"type": "openclaw_session_history_loaded",
"data": result,
"session_key": session_key,
}))
async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "cron.list")
await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result}))
async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "exec.approvals.get")
await websocket.send(json.dumps({"type": "openclaw_approvals_loaded", "data": result}))
async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "agents.list")
await websocket.send(json.dumps({"type": "openclaw_agents_loaded", "data": result}))
async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "node.list")
await websocket.send(json.dumps({"type": "openclaw_agents_presence_loaded", "data": result}))
async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "skills.status")
await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result}))
async def handle_get_openclaw_models(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_loaded", "data": result}))
async def handle_get_openclaw_hooks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "tools.catalog")
await websocket.send(json.dumps({"type": "openclaw_hooks_loaded", "data": result}))
async def handle_get_openclaw_plugins(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "config.get")
await websocket.send(json.dumps({"type": "openclaw_plugins_loaded", "data": result}))
async def handle_get_openclaw_secrets_audit(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "secrets.reload")
await websocket.send(json.dumps({"type": "openclaw_secrets_audit_loaded", "data": result}))
async def handle_get_openclaw_security_audit(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "gateway.identity.get")
await websocket.send(json.dumps({"type": "openclaw_security_audit_loaded", "data": result}))
async def handle_get_openclaw_daemon_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "doctor.memory.status")
await websocket.send(json.dumps({"type": "openclaw_daemon_status_loaded", "data": result}))
async def handle_get_openclaw_pairing(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "device.pair.list")
await websocket.send(json.dumps({"type": "openclaw_pairing_loaded", "data": result}))
async def handle_get_openclaw_qr(gateway, websocket, data: dict) -> None:
await websocket.send(json.dumps({"type": "openclaw_qr_loaded", "data": {"error": "QR code not available via WebSocket"}}))
async def handle_get_openclaw_update_status(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "update.run")
await websocket.send(json.dumps({"type": "openclaw_update_status_loaded", "data": result}))
async def handle_get_openclaw_models_aliases(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_aliases_loaded", "data": result}))
async def handle_get_openclaw_models_fallbacks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_fallbacks_loaded", "data": result}))
async def handle_get_openclaw_models_image_fallbacks(gateway, websocket, data: dict) -> None:
result = await _ws_call(gateway, "models.list")
await websocket.send(json.dumps({"type": "openclaw_models_image_fallbacks_loaded", "data": result}))
async def handle_get_openclaw_skill_update(gateway, websocket, data: dict) -> None:
slug = data.get("slug")
all_flag = data.get("all", False)
params = {}
if slug is not None:
params["slug"] = slug
if all_flag:
params["all"] = "true"
result = await _ws_call(gateway, "skills.update", params)
await websocket.send(json.dumps({"type": "openclaw_skill_update_loaded", "data": result}))
async def handle_get_openclaw_workspace_files(gateway, websocket, data: dict) -> None:
raw_workspace = data.get("workspace", "")
# Use the workspace param (which is actually the agent.id from frontend) as agent_id
agent_id = raw_workspace or "main"
result = await _ws_call(gateway, "agents.files.list", {"agentId": agent_id})
if isinstance(result, dict):
result["workspace"] = agent_id
await websocket.send(json.dumps({"type": "openclaw_workspace_files_loaded", "data": result}))
async def handle_get_openclaw_workspace_file(gateway, websocket, data: dict) -> None:
agent_id = data.get("agent_id", "main")
file_name = data.get("file_name", "")
if not file_name:
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": {"error": "file_name is required"}}))
return
result = await _ws_call(gateway, "agents.files.get", {"agentId": agent_id, "name": file_name})
await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": result}))

View File

@@ -0,0 +1,754 @@
# -*- coding: utf-8 -*-
"""Thin service wrapper around the OpenClaw CLI."""
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shared.models.openclaw import (
AgentSummary,
AgentsList,
ApprovalRequest,
ApprovalsList,
CronJob,
CronList,
DaemonStatus,
HookStatusEntry,
HookStatusReport,
ModelAliasesList,
ModelFallbacksList,
ModelRow,
ModelsList,
OpenClawStatus,
PairingListResponse,
PluginDiagnostic,
PluginRecord,
PluginsList,
QrCodeResponse,
SecretsAuditReport,
SecurityAuditResponse,
SecurityAuditReport,
SessionEntry,
SessionHistory,
SessionsList,
SkillStatusEntry,
SkillStatusReport,
SkillUpdateResult,
UpdateCheckResult,
UpdateStatusResponse,
normalize_agents,
normalize_approvals,
normalize_cron_jobs,
normalize_daemon_status,
normalize_hooks,
normalize_model_aliases,
normalize_model_fallbacks,
normalize_models,
normalize_pairing,
normalize_plugins,
normalize_qr,
normalize_security_audit,
normalize_secrets_audit,
normalize_session_history,
normalize_sessions,
normalize_skill_update,
normalize_skills,
normalize_status,
normalize_update_status,
)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
REFERENCE_OPENCLAW_ROOT = PROJECT_ROOT / "reference" / "openclaw"
REFERENCE_OPENCLAW_ENTRY = REFERENCE_OPENCLAW_ROOT / "openclaw.mjs"
class OpenClawCliError(RuntimeError):
"""Raised when the OpenClaw CLI invocation fails."""
def __init__(
self,
message: str,
*,
command: list[str],
exit_code: int | None = None,
stdout: str = "",
stderr: str = "",
) -> None:
super().__init__(message)
self.command = command
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
@dataclass(frozen=True)
class OpenClawCliResult:
"""Command execution result."""
command: list[str]
exit_code: int
stdout: str
stderr: str
def resolve_openclaw_base_command() -> list[str]:
"""Resolve the command prefix used to launch OpenClaw."""
explicit = os.getenv("OPENCLAW_CMD", "").strip()
if explicit:
return shlex.split(explicit)
installed = shutil.which("openclaw")
if installed:
return [installed]
if REFERENCE_OPENCLAW_ENTRY.exists():
return [sys.executable if sys.executable.endswith("node") else "node", str(REFERENCE_OPENCLAW_ENTRY)]
return ["openclaw"]
def resolve_openclaw_cwd() -> Path:
"""Resolve the working directory for CLI execution."""
explicit = os.getenv("OPENCLAW_CWD", "").strip()
if explicit:
return Path(explicit).expanduser()
if REFERENCE_OPENCLAW_ROOT.exists():
return REFERENCE_OPENCLAW_ROOT
return PROJECT_ROOT
class OpenClawCliService:
"""OpenClaw CLI integration service."""
def __init__(
self,
*,
base_command: list[str] | None = None,
cwd: Path | None = None,
timeout_seconds: float | None = None,
) -> None:
self.base_command = list(base_command or resolve_openclaw_base_command())
self.cwd = cwd or resolve_openclaw_cwd()
self.timeout_seconds = timeout_seconds or float(
os.getenv("OPENCLAW_TIMEOUT_SECONDS", "15")
)
def health(self) -> dict[str, Any]:
"""Return the current CLI wiring state."""
binary = self.base_command[0] if self.base_command else "openclaw"
resolved = shutil.which(binary) if len(self.base_command) == 1 else binary
return {
"status": "healthy",
"service": "openclaw-service",
"base_command": self.base_command,
"cwd": str(self.cwd),
"binary_resolved": resolved is not None,
"reference_entry_available": REFERENCE_OPENCLAW_ENTRY.exists(),
"timeout_seconds": self.timeout_seconds,
}
def status(self) -> dict[str, Any]:
"""Read `openclaw status --json`."""
return self.run_json(["status", "--json"])
def list_sessions(self) -> dict[str, Any]:
"""Read `openclaw sessions --json`."""
return self.run_json(["sessions", "--json"])
def get_session(self, session_key: str) -> dict[str, Any]:
"""Resolve a single session out of the sessions list."""
payload = self.list_sessions()
sessions = payload.get("sessions") or []
for item in sessions:
if not isinstance(item, dict):
continue
if item.get("key") == session_key or item.get("sessionKey") == session_key:
return item
raise KeyError(session_key)
def get_session_history(self, session_key: str, *, limit: int = 20) -> dict[str, Any]:
"""Read session history with a JSON-first fallback to raw text."""
args = ["sessions", "history", session_key, "--json", "--limit", str(limit)]
try:
return self.run_json(args)
except OpenClawCliError as exc:
raise exc
except json.JSONDecodeError:
result = self.run(args)
return {
"sessionKey": session_key,
"limit": limit,
"rawText": result.stdout,
}
def list_cron_jobs(self) -> dict[str, Any]:
"""Read `openclaw cron list --json`."""
return self.run_json(["cron", "list", "--json"])
def list_approvals(self) -> dict[str, Any]:
"""Read `openclaw approvals get --json`."""
return self.run_json(["approvals", "get", "--json"])
def list_agents(self) -> dict[str, Any]:
"""Read `openclaw agents list --json`."""
return self.run_json(["agents", "list", "--json"])
def list_skills(self) -> dict[str, Any]:
"""Read `openclaw skills list --json`."""
return self.run_json(["skills", "list", "--json"])
def list_models(self) -> dict[str, Any]:
"""Read `openclaw models list --json`."""
return self.run_json(["models", "list", "--json"])
def list_hooks(self) -> dict[str, Any]:
"""Read `openclaw hooks list --json`."""
return self.run_json(["hooks", "list", "--json"])
def list_plugins(self) -> dict[str, Any]:
"""Read `openclaw plugins list --json`."""
return self.run_json(["plugins", "list", "--json"])
def secrets_audit(self) -> dict[str, Any]:
"""Read `openclaw secrets audit --json`."""
return self.run_json(["secrets", "audit", "--json"])
def security_audit(self) -> dict[str, Any]:
"""Read `openclaw security audit --json`."""
return self.run_json(["security", "audit", "--json"])
def daemon_status(self) -> dict[str, Any]:
"""Read `openclaw daemon status --json`."""
return self.run_json(["daemon", "status", "--json"])
def pairing_list(self) -> dict[str, Any]:
"""Read `openclaw pairing list --json`."""
return self.run_json(["pairing", "list", "--json"])
def qr_code(self) -> dict[str, Any]:
"""Read `openclaw qr --json`."""
return self.run_json(["qr", "--json"])
def update_status(self) -> dict[str, Any]:
"""Read `openclaw update status --json`."""
return self.run_json(["update", "status", "--json"])
def list_model_aliases(self) -> dict[str, Any]:
"""Read `openclaw models aliases list --json`."""
return self.run_json(["models", "aliases", "list", "--json"])
def list_model_fallbacks(self) -> dict[str, Any]:
"""Read `openclaw models fallbacks list --json`."""
return self.run_json(["models", "fallbacks", "list", "--json"])
def list_model_image_fallbacks(self) -> dict[str, Any]:
"""Read `openclaw models image-fallbacks list --json`."""
return self.run_json(["models", "image-fallbacks", "list", "--json"])
def skill_update(self, *, slug: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw skills update --json`."""
args = ["skills", "update", "--json"]
if slug:
args.append(slug)
if all:
args.append("--all")
return self.run_json(args)
def models_status(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw models status --json [--probe]`."""
args = ["models", "status", "--json"]
if probe:
args.append("--probe")
return self.run_json(args)
def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw channels status [--probe] --json`."""
args = ["channels", "status", "--json"]
if probe:
args.append("--probe")
return self.run_json(args)
def list_workspace_files(self, workspace_path: str) -> dict[str, Any]:
"""List .md files in an OpenClaw agent workspace with their content.
Reads the workspace directory and returns metadata + content for each .md file.
"""
import json
from pathlib import Path
wp = Path(workspace_path).expanduser().resolve()
if not wp.exists() or not wp.is_dir():
return {"workspace": str(wp), "files": [], "error": "workspace not found"}
md_files = sorted(wp.glob("*.md"))
files = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8")
# Preview: first 300 chars
preview = content[:300].strip()
files.append({
"name": md_file.name,
"path": str(md_file),
"size": len(content),
"preview": preview,
"previewTruncated": len(content) > 300,
})
except OSError as exc:
files.append({
"name": md_file.name,
"path": str(md_file),
"size": 0,
"preview": "",
"error": str(exc),
})
return {"workspace": str(wp), "files": files}
def channels_list(self) -> dict[str, Any]:
"""Read `openclaw channels list --json`."""
return self.run_json(["channels", "list", "--json"])
def hook_info(self, name: str) -> dict[str, Any]:
"""Read `openclaw hooks info <name> --json`."""
args = ["hooks", "info", name, "--json"]
try:
return self.run_json(args)
except json.JSONDecodeError:
result = self.run(args)
return {"raw": result.stdout}
def hooks_check(self) -> dict[str, Any]:
"""Read `openclaw hooks check --json`."""
return self.run_json(["hooks", "check", "--json"])
def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw plugins inspect [--json] [--all]`."""
args = ["plugins", "inspect", "--json"]
if all:
args.append("--all")
elif plugin_id:
args.append(plugin_id)
return self.run_json(args)
# -------------------------------------------------------------------------
# Typed variants — these use Pydantic models and are the preferred path.
# -------------------------------------------------------------------------
def status_model(self) -> OpenClawStatus:
"""Read and parse `openclaw status --json` into a typed model."""
raw = self.status()
return normalize_status(raw)
def list_sessions_model(self) -> SessionsList:
"""Read and parse `openclaw sessions --json` into a typed model."""
raw = self.list_sessions()
return normalize_sessions(raw)
def get_session_model(self, session_key: str) -> SessionEntry:
"""Resolve a single session and return a typed model."""
raw = self.get_session(session_key)
return SessionEntry.model_validate(raw, strict=False)
def get_session_history_model(self, session_key: str, *, limit: int = 20) -> SessionHistory:
"""Read session history and return a typed model."""
raw = self.get_session_history(session_key, limit=limit)
return normalize_session_history(raw, session_key=session_key)
def list_cron_jobs_model(self) -> CronList:
"""Read and parse `openclaw cron list --json` into a typed model."""
raw = self.list_cron_jobs()
return normalize_cron_jobs(raw)
def list_approvals_model(self) -> ApprovalsList:
"""Read and parse `openclaw approvals get --json` into a typed model."""
raw = self.list_approvals()
return normalize_approvals(raw)
# -------------------------------------------------------------------------
# Typed variants
# -------------------------------------------------------------------------
def list_agents_model(self) -> AgentsList:
"""Read and parse `openclaw agents list --json` into a typed model."""
raw = self.list_agents()
if isinstance(raw, list):
return AgentsList(agents=[AgentSummary.model_validate(a, strict=False) for a in raw if isinstance(a, dict)])
return normalize_agents(raw)
def list_skills_model(self) -> SkillStatusReport:
"""Read and parse `openclaw skills list --json` into a typed model."""
raw = self.list_skills()
return normalize_skills(raw)
def list_models_model(self) -> ModelsList:
"""Read and parse `openclaw models list --json` into a typed model."""
raw = self.list_models()
if isinstance(raw, list):
return ModelsList(models=[ModelRow.model_validate(m, strict=False) for m in raw if isinstance(m, dict)])
return normalize_models(raw)
def list_hooks_model(self) -> HookStatusReport:
raw = self.list_hooks()
return normalize_hooks(raw)
def list_plugins_model(self) -> PluginsList:
raw = self.list_plugins()
return normalize_plugins(raw)
def secrets_audit_model(self) -> SecretsAuditReport:
raw = self.secrets_audit()
return normalize_secrets_audit(raw)
def security_audit_model(self) -> SecurityAuditResponse:
raw = self.security_audit()
return normalize_security_audit(raw)
def daemon_status_model(self) -> DaemonStatus:
raw = self.daemon_status()
return normalize_daemon_status(raw)
def pairing_list_model(self) -> PairingListResponse:
raw = self.pairing_list()
return normalize_pairing(raw)
def qr_code_model(self) -> QrCodeResponse:
raw = self.qr_code()
return normalize_qr(raw)
def update_status_model(self) -> UpdateStatusResponse:
raw = self.update_status()
return normalize_update_status(raw)
def list_model_aliases_model(self) -> ModelAliasesList:
raw = self.list_model_aliases()
return normalize_model_aliases(raw)
def list_model_fallbacks_model(self) -> ModelFallbacksList:
raw = self.list_model_fallbacks()
return normalize_model_fallbacks(raw)
def list_model_image_fallbacks_model(self) -> ModelFallbacksList:
raw = self.list_model_image_fallbacks()
return normalize_model_fallbacks(raw)
def skill_update_model(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
raw = self.skill_update(slug=slug, all=all)
return normalize_skill_update(raw)
def models_status_model(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw models status --json` and return the raw dict."""
return self.models_status(probe=probe)
def channels_status_model(self, *, probe: bool = False) -> dict[str, Any]:
"""Read `openclaw channels status --json` and return the raw dict."""
return self.channels_status(probe=probe)
def channels_list_model(self) -> dict[str, Any]:
"""Read `openclaw channels list --json` and return the raw dict."""
return self.channels_list()
def hook_info_model(self, name: str) -> dict[str, Any]:
"""Read `openclaw hooks info <name> --json` and return the raw dict."""
return self.hook_info(name)
def hooks_check_model(self) -> dict[str, Any]:
"""Read `openclaw hooks check --json` and return the raw dict."""
return self.hooks_check()
def plugins_inspect_model(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
"""Read `openclaw plugins inspect --json [--all]` and return the raw dict."""
return self.plugins_inspect(plugin_id=plugin_id, all=all)
def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
"""Read `openclaw agents bindings --json [--agent <id>]`."""
args = ["agents", "bindings", "--json"]
if agent:
args.extend(["--agent", agent])
return self.run_json(args)
def agents_bindings_model(self, *, agent: str | None = None) -> dict[str, Any]:
"""Read `openclaw agents bindings --json` and return the raw dict."""
return self.agents_bindings(agent=agent)
def agents_presence(self) -> dict[str, Any]:
"""Read session presence for all agents from runtime session files.
Reads ~/.openclaw/agents/{agentId}/sessions/sessions.json for each agent
and counts sessions in active states within a recency window.
"""
import json
from pathlib import Path
openclaw_home = Path.home() / ".openclaw"
agents_path = openclaw_home / "agents"
if not agents_path.exists():
return {"status": "not_connected", "agents": {}}
ACTIVE_STATES = {
"running", "active", "busy", "blocked", "waiting_approval",
"working", "in_progress", "processing", "thinking", "executing", "streaming",
}
RECENCY_WINDOW_MS = 45 * 60 * 1000 # 45 minutes
result: dict[str, Any] = {"status": "connected", "agents": {}}
try:
for agent_dir in agents_path.iterdir():
if not agent_dir.is_dir():
continue
sessions_file = agent_dir / "sessions" / "sessions.json"
if not sessions_file.exists():
continue
try:
sessions_data = json.loads(sessions_file.read_text())
except (json.JSONDecodeError, OSError):
continue
sessions = sessions_data if isinstance(sessions_data, list) else []
now_ms = 0 # placeholder; we'll skip recency check if no ts field
active_count = 0
for session in sessions:
if not isinstance(session, dict):
continue
state = str(session.get("state") or session.get("status") or "").lower()
if state in ACTIVE_STATES:
active_count += 1
if active_count > 0:
result["agents"][agent_dir.name] = {
"activeSessions": active_count,
"status": "active",
}
else:
result["agents"][agent_dir.name] = {
"activeSessions": 0,
"status": "idle",
}
except OSError:
result["status"] = "partial"
return result
def agents_from_config(self) -> dict[str, Any]:
"""Read agent list directly from openclaw.json config file.
Falls back to scanning ~/.openclaw/agents/ directories when config is absent.
This avoids the CLI timeout from `agents list --json`.
"""
import json
openclaw_home = Path.home() / ".openclaw"
config_path = openclaw_home / "openclaw.json"
if not config_path.exists():
return {"status": "not_connected", "agents": []}
try:
raw = json.loads(config_path.read_text())
except (json.JSONDecodeError, OSError):
return {"status": "partial", "agents": []}
agents_list = raw.get("agents", {}).get("list", [])
if not agents_list:
return {"status": "partial", "agents": [], "detail": "agents.list is empty"}
agents = []
for entry in agents_list:
if not isinstance(entry, dict):
continue
agent_id = entry.get("id", "").strip()
if not agent_id:
continue
agents.append({
"id": agent_id,
"name": entry.get("name", "").strip() or agent_id,
"model": entry.get("model") or "",
"workspace": entry.get("workspace") or "",
"is_default": entry.get("id") == raw.get("agents", {}).get("defaults", {}).get("id"),
})
return {"status": "connected", "agents": agents}
def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. May fail if gateway is unreachable."""
args = ["gateway", "status", "--json"]
if url:
args.extend(["--url", url])
if token:
args.extend(["--token", token])
return self.run_json(args)
def memory_status(self, *, agent: str | None = None, deep: bool = False) -> dict[str, Any]:
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent status."""
args = ["memory", "status", "--json"]
if agent:
args.extend(["--agent", agent])
if deep:
args.append("--deep")
return self.run_json(args)
# -------------------------------------------------------------------------
# Write agents commands
# -------------------------------------------------------------------------
def agents_add(
self,
name: str,
*,
workspace: str | None = None,
model: str | None = None,
agent_dir: str | None = None,
bind: list[str] | None = None,
non_interactive: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents add <name> [--workspace <dir>] [--model <id>] [--agent-dir <dir>] [--bind <spec>] [--non-interactive] --json`."""
args = ["agents", "add", name, "--json"]
if workspace:
args.extend(["--workspace", workspace])
if model:
args.extend(["--model", model])
if agent_dir:
args.extend(["--agent-dir", agent_dir])
if bind:
for b in bind:
args.extend(["--bind", b])
if non_interactive:
args.append("--non-interactive")
return self.run_json(args)
def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
"""Run `openclaw agents delete <id> [--force] --json`."""
args = ["agents", "delete", id, "--json"]
if force:
args.append("--force")
return self.run_json(args)
def agents_bind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
) -> dict[str, Any]:
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>] --json`."""
args = ["agents", "bind", "--json"]
if agent:
args.extend(["--agent", agent])
if bind:
for b in bind:
args.extend(["--bind", b])
return self.run_json(args)
def agents_unbind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
all: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all] --json`."""
args = ["agents", "unbind", "--json"]
if agent:
args.extend(["--agent", agent])
if bind:
for b in bind:
args.extend(["--bind", b])
if all:
args.append("--all")
return self.run_json(args)
def agents_set_identity(
self,
*,
agent: str | None = None,
workspace: str | None = None,
identity_file: str | None = None,
name: str | None = None,
emoji: str | None = None,
theme: str | None = None,
avatar: str | None = None,
from_identity: bool = False,
) -> dict[str, Any]:
"""Run `openclaw agents set-identity [--agent <id>] [--workspace <dir>] [--identity-file <path>] [--from-identity] [--name <n>] [--emoji <e>] [--theme <t>] [--avatar <a>] --json`."""
args = ["agents", "set-identity", "--json"]
if agent:
args.extend(["--agent", agent])
if workspace:
args.extend(["--workspace", workspace])
if identity_file:
args.extend(["--identity-file", identity_file])
if from_identity:
args.append("--from-identity")
if name:
args.extend(["--name", name])
if emoji:
args.extend(["--emoji", emoji])
if theme:
args.extend(["--theme", theme])
if avatar:
args.extend(["--avatar", avatar])
return self.run_json(args)
def run_json(self, args: list[str]) -> dict[str, Any]:
"""Run the CLI and decode JSON stdout, falling back to stderr."""
result = self.run(args)
text = result.stdout.strip() or result.stderr.strip()
if not text:
return {}
return json.loads(text)
def run(self, args: list[str]) -> OpenClawCliResult:
"""Run the CLI and return stdout/stderr."""
command = [*self.base_command, *args]
env = os.environ.copy()
try:
completed = subprocess.run(
command,
cwd=self.cwd,
env=env,
capture_output=True,
text=True,
timeout=self.timeout_seconds,
check=False,
)
except FileNotFoundError as exc:
raise OpenClawCliError(
"OpenClaw CLI executable was not found.",
command=command,
) from exc
except subprocess.TimeoutExpired as exc:
raise OpenClawCliError(
f"OpenClaw CLI timed out after {self.timeout_seconds:.1f}s.",
command=command,
stdout=exc.stdout or "",
stderr=exc.stderr or "",
) from exc
if completed.returncode != 0:
raise OpenClawCliError(
"OpenClaw CLI command failed.",
command=command,
exit_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
return OpenClawCliResult(
command=command,
exit_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)