1 Commits

Author SHA1 Message Date
6ecc224427 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>
2026-03-27 11:08:15 +08:00
20 changed files with 5691 additions and 6 deletions

View File

@@ -137,7 +137,7 @@ class RunWorkspaceManager:
filename: str, filename: str,
) -> str: ) -> str:
"""Load one run-scoped agent workspace file.""" """Load one run-scoped agent workspace file."""
path = self.get_agent_asset_dir(config_name, agent_id) / filename path = self.skills_manager.get_agent_asset_dir(config_name, agent_id) / filename
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"File not found: {filename}") raise FileNotFoundError(f"File not found: {filename}")
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
@@ -151,7 +151,7 @@ class RunWorkspaceManager:
content: str, content: str,
) -> None: ) -> None:
"""Write one run-scoped agent workspace file.""" """Write one run-scoped agent workspace file."""
asset_dir = self.get_agent_asset_dir(config_name, agent_id) asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id)
asset_dir.mkdir(parents=True, exist_ok=True) asset_dir.mkdir(parents=True, exist_ok=True)
path = asset_dir / filename path = asset_dir / filename
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")

839
backend/api/openclaw.py Normal file
View File

@@ -0,0 +1,839 @@
# -*- coding: utf-8 -*-
"""Read-only OpenClaw CLI API routes — typed with Pydantic models."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
from shared.models.openclaw import OpenClawStatus
router = APIRouter(prefix="/api/openclaw", tags=["openclaw"])
def get_openclaw_cli_service() -> OpenClawCliService:
"""Build the OpenClaw CLI service dependency."""
return OpenClawCliService()
def _raise_cli_http_error(exc: OpenClawCliError) -> None:
detail = {
"message": str(exc),
"command": exc.command,
"exit_code": exc.exit_code,
"stdout": exc.stdout,
"stderr": exc.stderr,
}
status_code = 503 if exc.exit_code is None else 502
raise HTTPException(status_code=status_code, detail=detail) from exc
# ---------------------------------------------------------------------------
# Response wrappers
# ---------------------------------------------------------------------------
class StatusResponse(BaseModel):
status: object
class SessionsResponse(BaseModel):
sessions: list[object]
class SessionDetailResponse(BaseModel):
session: object | None
class SessionHistoryResponse(BaseModel):
session_key: str
session_id: str | None
events: list[object]
history: list[object]
raw_text: str | None
class CronResponse(BaseModel):
cron: list[object]
jobs: list[object]
class ApprovalsResponse(BaseModel):
approvals: list[object]
pending: list[object]
class AgentsResponse(BaseModel):
agents: list[object]
class SkillsResponse(BaseModel):
workspace_dir: str
managed_skills_dir: str
skills: list[object]
class ModelsResponse(BaseModel):
models: list[object]
class HooksResponse(BaseModel):
workspace_dir: str
managed_hooks_dir: str
hooks: list[object]
class PluginsResponse(BaseModel):
workspace_dir: str
plugins: list[object]
diagnostics: list[object]
class SecretsAuditResponse(BaseModel):
version: int
status: str
findings: list[object]
class SecurityAuditResponse2(BaseModel):
report: object | None
secret_diagnostics: list[str]
class DaemonStatusResponse(BaseModel):
service: object | None
port: object | None
rpc: object | None
health: object | None
class PairingListResponse2(BaseModel):
channel: str
requests: list[object]
class QrCodeResponse2(BaseModel):
setup_code: str
gateway_url: str
auth: str
url_source: str
class UpdateStatusResponse2(BaseModel):
update: object | None
channel: object | None
class ModelAliasesResponse(BaseModel):
aliases: dict[str, str]
class ModelFallbacksResponse(BaseModel):
key: str
label: str
items: list[object]
class SkillUpdateResponse(BaseModel):
ok: bool
slug: str
version: str
error: str | None
class ModelsStatusResponse(BaseModel):
configPath: str | None = None
agentId: str | None = None
agentDir: str | None = None
defaultModel: str | None = None
resolvedDefault: str | None = None
fallbacks: list[str] = Field(default_factory=list)
imageModel: str | None = None
imageFallbacks: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
allowed: list[str] = Field(default_factory=list)
auth: dict[str, Any] = Field(default_factory=dict)
class ChannelsStatusResponse(BaseModel):
reachable: bool | None = None
channelAccounts: dict[str, Any] = Field(default_factory=dict)
channels: list[str] = Field(default_factory=list)
issues: list[dict[str, Any]] = Field(default_factory=list)
class ChannelsListResponse(BaseModel):
chat: dict[str, list[str]] = Field(default_factory=dict)
auth: list[dict[str, Any]] = Field(default_factory=list)
usage: dict[str, Any] | None = None
class HookInfoResponse(BaseModel):
name: str | None = None
description: str | None = None
source: str | None = None
pluginId: str | None = None
filePath: str | None = None
handlerPath: str | None = None
hookKey: str | None = None
emoji: str | None = None
homepage: str | None = None
events: list[str] = Field(default_factory=list)
enabledByConfig: bool | None = None
loadable: bool | None = None
requirementsSatisfied: bool | None = None
requirements: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
raw: str | None = None
class HooksCheckResponse(BaseModel):
workspace_dir: str = ""
managed_hooks_dir: str = ""
hooks: list[dict[str, Any]] = Field(default_factory=list)
eligible: bool | None = None
verbose: bool | None = None
class PluginInspectEntry(BaseModel):
plugin: dict[str, Any] = Field(default_factory=dict)
shape: str | None = None
capabilityMode: str | None = None
capabilityCount: int = 0
capabilities: list[dict[str, Any]] = Field(default_factory=list)
typedHooks: list[dict[str, Any]] = Field(default_factory=list)
customHooks: list[dict[str, Any]] = Field(default_factory=list)
tools: list[dict[str, Any]] = Field(default_factory=list)
commands: list[str] = Field(default_factory=list)
cliCommands: list[str] = Field(default_factory=list)
services: list[str] = Field(default_factory=list)
gatewayMethods: list[str] = Field(default_factory=list)
mcpServers: list[dict[str, Any]] = Field(default_factory=list)
lspServers: list[dict[str, Any]] = Field(default_factory=list)
httpRouteCount: int = 0
bundleCapabilities: list[str] = Field(default_factory=list)
class PluginsInspectResponse(BaseModel):
inspect: list[dict[str, Any]] = Field(default_factory=list)
class AgentBindingItem(BaseModel):
agentId: str
match: dict[str, Any]
description: str
class AgentsBindingsResponse(BaseModel):
bindings: list[AgentBindingItem]
# ---------------------------------------------------------------------------
# Routes — use typed model methods and return Pydantic models directly
# ---------------------------------------------------------------------------
@router.get("/status")
async def api_openclaw_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> OpenClawStatus:
"""Read `openclaw status --json` and return a typed model."""
try:
return service.status_model()
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions")
async def api_openclaw_sessions(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionsResponse:
"""Read `openclaw sessions --json` and return a typed SessionsList."""
try:
result = service.list_sessions_model()
return SessionsResponse(sessions=result.sessions)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions/{session_key:path}/history")
async def api_openclaw_session_history(
session_key: str,
limit: int = Query(20, ge=1, le=200),
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionHistoryResponse:
"""Read session history and return a typed SessionHistory."""
try:
result = service.get_session_history_model(session_key, limit=limit)
return SessionHistoryResponse(
session_key=result.session_key,
session_id=result.session_id,
events=result.events,
history=result.events, # alias for compat
raw_text=result.raw_text,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/sessions/{session_key:path}")
async def api_openclaw_session_detail(
session_key: str,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SessionDetailResponse:
"""Resolve a single session and return it as a typed model."""
try:
session = service.get_session_model(session_key)
return SessionDetailResponse(session=session)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"session '{session_key}' not found") from exc
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/cron")
async def api_openclaw_cron(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> CronResponse:
"""Read `openclaw cron list --json` and return a typed CronList."""
try:
result = service.list_cron_jobs_model()
return CronResponse(cron=list(result.cron), jobs=list(result.jobs))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/approvals")
async def api_openclaw_approvals(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ApprovalsResponse:
"""Read `openclaw approvals get --json` and return a typed ApprovalsList."""
try:
result = service.list_approvals_model()
return ApprovalsResponse(
approvals=list(result.approvals),
pending=list(result.pending),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/agents")
async def api_openclaw_agents(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentsResponse:
"""Read `openclaw agents list --json` and return a typed AgentsList."""
try:
result = service.list_agents_model()
return AgentsResponse(agents=list(result.agents))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/agents/presence")
async def api_openclaw_agents_presence(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> dict[str, Any]:
"""Read runtime session presence for all agents from session files."""
result = service.agents_presence()
return result
# ---------------------------------------------------------------------------
# Write agents routes
# ---------------------------------------------------------------------------
class AgentAddResponse(BaseModel):
agentId: str
name: str
workspace: str
agentDir: str
model: str | None = None
bindings: dict[str, Any] = Field(default_factory=dict)
class AgentDeleteResponse(BaseModel):
agentId: str
workspace: str
agentDir: str
sessionsDir: str
removedBindings: list[str] = Field(default_factory=list)
removedAllow: list[str] = Field(default_factory=list)
class AgentBindResponse(BaseModel):
agentId: str
added: list[str] = Field(default_factory=list)
updated: list[str] = Field(default_factory=list)
skipped: list[str] = Field(default_factory=list)
conflicts: list[str] = Field(default_factory=list)
class AgentUnbindResponse(BaseModel):
agentId: str
removed: list[str] = Field(default_factory=list)
missing: list[str] = Field(default_factory=list)
conflicts: list[str] = Field(default_factory=list)
class AgentIdentityResponse(BaseModel):
agentId: str
identity: dict[str, Any] = Field(default_factory=dict)
workspace: str | None = None
identityFile: str | None = None
@router.post("/agents/add")
async def api_openclaw_agents_add(
name: str,
*,
workspace: str | None = None,
model: str | None = None,
agent_dir: str | None = None,
bind: list[str] | None = None,
non_interactive: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentAddResponse:
"""Run `openclaw agents add <name>` and return JSON result."""
try:
result = service.agents_add(
name,
workspace=workspace,
model=model,
agent_dir=agent_dir,
bind=bind,
non_interactive=non_interactive,
)
return AgentAddResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/delete/{id}")
async def api_openclaw_agents_delete(
id: str,
force: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentDeleteResponse:
"""Run `openclaw agents delete <id> [--force]` and return JSON result."""
try:
result = service.agents_delete(id, force=force)
return AgentDeleteResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/bind")
async def api_openclaw_agents_bind(
*,
agent: str | None = None,
bind: list[str] | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentBindResponse:
"""Run `openclaw agents bind [--agent <id>] [--bind <spec>]` and return JSON result."""
try:
result = service.agents_bind(agent=agent, bind=bind)
return AgentBindResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/unbind")
async def api_openclaw_agents_unbind(
*,
agent: str | None = None,
bind: list[str] | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentUnbindResponse:
"""Run `openclaw agents unbind [--agent <id>] [--bind <spec>] [--all]` and return JSON result."""
try:
result = service.agents_unbind(agent=agent, bind=bind, all=all)
return AgentUnbindResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.post("/agents/set-identity")
async def api_openclaw_agents_set_identity(
*,
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,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentIdentityResponse:
"""Run `openclaw agents set-identity` and return JSON result."""
try:
result = service.agents_set_identity(
agent=agent,
workspace=workspace,
identity_file=identity_file,
name=name,
emoji=emoji,
theme=theme,
avatar=avatar,
from_identity=from_identity,
)
return AgentIdentityResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/skills")
async def api_openclaw_skills(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SkillsResponse:
"""Read `openclaw skills list --json` and return a typed SkillStatusReport."""
try:
result = service.list_skills_model()
return SkillsResponse(
workspace_dir=result.workspace_dir,
managed_skills_dir=result.managed_skills_dir,
skills=list(result.skills),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models")
async def api_openclaw_models(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelsResponse:
"""Read `openclaw models list --json` and return a typed ModelsList."""
try:
result = service.list_models_model()
return ModelsResponse(models=list(result.models))
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks")
async def api_openclaw_hooks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HooksResponse:
try:
result = service.list_hooks_model()
return HooksResponse(
workspace_dir=result.workspace_dir,
managed_hooks_dir=result.managed_hooks_dir,
hooks=list(result.hooks),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/plugins")
async def api_openclaw_plugins(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PluginsResponse:
try:
result = service.list_plugins_model()
return PluginsResponse(
workspace_dir=result.workspace_dir,
plugins=list(result.plugins),
diagnostics=list(result.diagnostics),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/secrets-audit")
async def api_openclaw_secrets_audit(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SecretsAuditResponse:
try:
result = service.secrets_audit_model()
return SecretsAuditResponse(
version=result.version,
status=result.status,
findings=list(result.findings),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/security-audit")
async def api_openclaw_security_audit(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SecurityAuditResponse2:
try:
result = service.security_audit_model()
return SecurityAuditResponse2(
report=result.report.model_dump() if result.report else None,
secret_diagnostics=list(result.secret_diagnostics),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/daemon-status")
async def api_openclaw_daemon_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> DaemonStatusResponse:
try:
result = service.daemon_status_model()
return DaemonStatusResponse(
service=result.service.model_dump() if result.service else None,
port=result.port.model_dump() if result.port else None,
rpc=result.rpc.model_dump() if result.rpc else None,
health=result.health.model_dump() if result.health else None,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/pairing")
async def api_openclaw_pairing(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PairingListResponse2:
try:
result = service.pairing_list_model()
return PairingListResponse2(
channel=result.channel,
requests=list(result.requests),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/qr")
async def api_openclaw_qr(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> QrCodeResponse2:
try:
result = service.qr_code_model()
return QrCodeResponse2(
setup_code=result.setup_code,
gateway_url=result.gateway_url,
auth=result.auth,
url_source=result.url_source,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/update-status")
async def api_openclaw_update_status(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> UpdateStatusResponse2:
try:
result = service.update_status_model()
return UpdateStatusResponse2(
update=result.update.model_dump() if result.update else None,
channel=result.channel.model_dump() if result.channel else None,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-aliases")
async def api_openclaw_models_aliases(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelAliasesResponse:
try:
result = service.list_model_aliases_model()
return ModelAliasesResponse(aliases=result.aliases)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-fallbacks")
async def api_openclaw_models_fallbacks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelFallbacksResponse:
try:
result = service.list_model_fallbacks_model()
return ModelFallbacksResponse(
key=result.key,
label=result.label,
items=list(result.items),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-image-fallbacks")
async def api_openclaw_models_image_fallbacks(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelFallbacksResponse:
try:
result = service.list_model_image_fallbacks_model()
return ModelFallbacksResponse(
key=result.key,
label=result.label,
items=list(result.items),
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/skill-update")
async def api_openclaw_skill_update(
slug: str | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> SkillUpdateResponse:
try:
result = service.skill_update_model(slug=slug, all=all)
return SkillUpdateResponse(
ok=result.ok,
slug=result.slug,
version=result.version,
error=result.error,
)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/models-status")
async def api_openclaw_models_status(
probe: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ModelsStatusResponse:
"""Read `openclaw models status --json [--probe]` and return a typed dict."""
try:
result = service.models_status_model(probe=probe)
return ModelsStatusResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/channels-status")
async def api_openclaw_channels_status(
probe: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ChannelsStatusResponse:
"""Read `openclaw channels status --json [--probe]` and return a typed dict."""
try:
result = service.channels_status_model(probe=probe)
return ChannelsStatusResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/channels-list")
async def api_openclaw_channels_list(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> ChannelsListResponse:
"""Read `openclaw channels list --json` and return a typed dict."""
try:
result = service.channels_list_model()
return ChannelsListResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks/info/{name}")
async def api_openclaw_hook_info(
name: str,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HookInfoResponse:
"""Read `openclaw hooks info <name> --json` and return a typed dict."""
try:
result = service.hook_info_model(name)
return HookInfoResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/hooks/check")
async def api_openclaw_hooks_check(
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> HooksCheckResponse:
"""Read `openclaw hooks check --json` and return a typed dict."""
try:
result = service.hooks_check_model()
return HooksCheckResponse.model_validate(result, strict=False)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/plugins-inspect")
async def api_openclaw_plugins_inspect(
plugin_id: str | None = None,
all: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> PluginsInspectResponse:
"""Read `openclaw plugins inspect --json [--all]` and return a typed dict."""
try:
result = service.plugins_inspect_model(plugin_id=plugin_id, all=all)
inspect = result if isinstance(result, list) else result.get("inspect", [])
return PluginsInspectResponse(inspect=inspect)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
class AgentBindingItem(BaseModel):
agentId: str
match: dict[str, Any]
description: str
class AgentsBindingsResponse(BaseModel):
bindings: list[AgentBindingItem]
@router.get("/agents-bindings")
async def api_openclaw_agents_bindings(
agent: str | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> AgentsBindingsResponse:
"""Read `openclaw agents bindings --json [--agent <id>]` and return bindings list."""
try:
result = service.agents_bindings_model(agent=agent)
bindings = result if isinstance(result, list) else []
return AgentsBindingsResponse(bindings=bindings)
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/gateway-status")
async def api_openclaw_gateway_status(
url: str | None = None,
token: str | None = None,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> dict[str, Any]:
"""Read `openclaw gateway status --json [--url <url>] [--token <token>]`. Returns full gateway probe result."""
try:
result = service.gateway_status(url=url, token=token)
return result
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
@router.get("/memory-status")
async def api_openclaw_memory_status(
agent: str | None = None,
deep: bool = False,
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> list[dict[str, Any]]:
"""Read `openclaw memory status --json [--agent <id>] [--deep]`. Returns array of per-agent memory status."""
try:
result = service.memory_status(agent=agent, deep=deep)
return result if isinstance(result, list) else []
except OpenClawCliError as exc:
_raise_cli_http_error(exc)
class WorkspaceFilesResponse(BaseModel):
workspace: str
files: list[dict[str, Any]]
error: str | None = None
@router.get("/workspace-files")
async def api_openclaw_workspace_files(
workspace: str = Query(..., description="Path to the agent workspace directory"),
service: OpenClawCliService = Depends(get_openclaw_cli_service),
) -> WorkspaceFilesResponse:
"""List .md files in an OpenClaw agent workspace with their content previews."""
result = service.list_workspace_files(workspace)
return WorkspaceFilesResponse.model_validate(result, strict=False)

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Read-only OpenClaw CLI FastAPI surface."""
from __future__ import annotations
from fastapi import Depends, FastAPI
from backend.api import openclaw_router
from backend.apps.cors import add_cors_middleware
from backend.api.openclaw import get_openclaw_cli_service
def create_app() -> FastAPI:
"""Create the OpenClaw service app."""
app = FastAPI(
title="EvoTraders OpenClaw Service",
description="Read-only OpenClaw CLI integration service surface",
version="0.1.0",
)
add_cors_middleware(app)
@app.get("/health")
async def health_check(
service=Depends(get_openclaw_cli_service),
) -> dict[str, object]:
return service.health()
@app.get("/api/status")
async def api_status(
service=Depends(get_openclaw_cli_service),
) -> dict[str, object]:
return {
"status": "operational",
"service": "openclaw-service",
"openclaw": service.health(),
}
app.include_router(openclaw_router)
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -72,14 +72,16 @@ class SuppressNoisyInfoFilter(logging.Filter):
"""Filter out low-signal library INFO logs while keeping warnings/errors.""" """Filter out low-signal library INFO logs while keeping warnings/errors."""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
if record.levelno >= logging.WARNING:
return True
message = record.getMessage() message = record.getMessage()
if record.name == "httpx" and message.startswith("HTTP Request:"): if record.name == "httpx" and message.startswith("HTTP Request:"):
return False return False
if record.name.startswith("websockets") and "connection open" in message: if record.name.startswith("websockets") and "connection open" in message:
return False return False
if record.name.startswith("websockets") and "opening handshake failed" in message:
return False
if record.levelno >= logging.WARNING:
return True
return True return True

View File

@@ -29,6 +29,7 @@ from backend.runtime.manager import (
set_global_runtime_manager, set_global_runtime_manager,
clear_global_runtime_manager, clear_global_runtime_manager,
) )
from backend.gateway_server import configure_gateway_logging
from backend.services.gateway import Gateway from backend.services.gateway import Gateway
from backend.services.market import MarketService from backend.services.market import MarketService
from backend.services.storage import StorageService from backend.services.storage import StorageService
@@ -38,6 +39,7 @@ load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
loguru.logger.disable("flowllm") loguru.logger.disable("flowllm")
loguru.logger.disable("reme_ai") loguru.logger.disable("reme_ai")
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
_prompt_loader = get_prompt_loader() _prompt_loader = get_prompt_loader()

View File

@@ -26,10 +26,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer
from backend.core.scheduler import Scheduler from backend.core.scheduler import Scheduler
from backend.services import gateway_admin_handlers from backend.services import gateway_admin_handlers
from backend.services import gateway_cycle_support 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_runtime_support
from backend.services import gateway_stock_handlers from backend.services import gateway_stock_handlers
from shared.client import NewsServiceClient from shared.client import NewsServiceClient
from shared.client import TradingServiceClient from shared.client import TradingServiceClient
from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
EDITABLE_AGENT_WORKSPACE_FILES = { EDITABLE_AGENT_WORKSPACE_FILES = {
@@ -92,6 +94,7 @@ class Gateway:
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._project_root = Path(__file__).resolve().parents[2] self._project_root = Path(__file__).resolve().parents[2]
self._technical_analyzer = StockTechnicalAnalyzer() self._technical_analyzer = StockTechnicalAnalyzer()
self._openclaw_ws: OpenClawWebSocketClient | None = None
async def start(self, host: str = "0.0.0.0", port: int = 8766): async def start(self, host: str = "0.0.0.0", port: int = 8766):
"""Start gateway server with proper initialization order. """Start gateway server with proper initialization order.
@@ -185,6 +188,20 @@ class Gateway:
# Give a brief moment for any existing clients to reconnect # Give a brief moment for any existing clients to reconnect
await asyncio.sleep(0.1) 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 # PHASE 2: Start market data service
# Now frontend is connected, start pushing price updates # Now frontend is connected, start pushing price updates
@@ -434,6 +451,54 @@ class Gateway:
await self._handle_get_stock_technical_indicators(websocket, data) await self._handle_get_stock_technical_indicators(websocket, data)
elif msg_type == "run_stock_enrich": elif msg_type == "run_stock_enrich":
await self._handle_run_stock_enrich(websocket, data) 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: except websockets.ConnectionClosed:
pass pass
@@ -669,6 +734,83 @@ class Gateway:
) -> None: ) -> None:
await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data) 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 @staticmethod
def _normalize_watchlist(raw_tickers: Any) -> List[str]: def _normalize_watchlist(raw_tickers: Any) -> List[str]:
return gateway_runtime_support.normalize_watchlist(raw_tickers) 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,
)

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Tests for the OpenClaw CLI service wrapper."""
from pathlib import Path
import pytest
from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService
class _Completed:
def __init__(self, *, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path):
captured = {}
def _fake_run(command, **kwargs):
captured["command"] = command
captured["cwd"] = kwargs["cwd"]
return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
payload = service.list_sessions()
assert payload["sessions"][0]["key"] == "main/session-1"
assert captured["command"] == ["openclaw", "sessions", "--json"]
assert captured["cwd"] == tmp_path
def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(returncode=7, stdout="", stderr="boom")
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
with pytest.raises(OpenClawCliError) as exc_info:
service.list_cron_jobs()
assert exc_info.value.exit_code == 7
assert exc_info.value.stderr == "boom"
def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path):
def _fake_run(command, **kwargs):
return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}')
monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run)
service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3)
session = service.get_session("main/session-1")
assert session["agentId"] == "main"

View File

@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""Tests for the extracted OpenClaw service app surface."""
from fastapi.testclient import TestClient
from backend.apps.openclaw_service import create_app
from backend.api import openclaw as openclaw_module
class _FakeOpenClawCliService:
def health(self):
return {
"status": "healthy",
"service": "openclaw-service",
"base_command": ["openclaw"],
"cwd": "/tmp/openclaw",
"binary_resolved": True,
"reference_entry_available": True,
"timeout_seconds": 15.0,
}
def status(self):
return {"runtimeVersion": "2026.3.24"}
def list_sessions(self):
return {
"sessions": [
{"key": "main/session-1", "agentId": "main"},
{"key": "analyst/session-2", "agentId": "analyst"},
]
}
def get_session(self, session_key: str):
for session in self.list_sessions()["sessions"]:
if session["key"] == session_key:
return session
raise KeyError(session_key)
def get_session_history(self, session_key: str, *, limit: int = 20):
return {
"sessionKey": session_key,
"limit": limit,
"items": [{"role": "assistant", "text": "hello"}],
}
def list_cron_jobs(self):
return {"jobs": [{"id": "job-1", "name": "Daily sync"}]}
def list_approvals(self):
return {"approvals": [{"id": "ap-1", "status": "pending"}]}
def test_openclaw_service_routes_are_exposed():
app = create_app()
paths = {route.path for route in app.routes}
assert "/health" in paths
assert "/api/status" in paths
assert "/api/openclaw/status" in paths
assert "/api/openclaw/sessions" in paths
assert "/api/openclaw/sessions/{session_key:path}" in paths
assert "/api/openclaw/sessions/{session_key:path}/history" in paths
assert "/api/openclaw/cron" in paths
assert "/api/openclaw/approvals" in paths
def test_openclaw_service_read_routes():
app = create_app()
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
lambda: _FakeOpenClawCliService()
)
with TestClient(app) as client:
health = client.get("/health")
status = client.get("/api/status")
openclaw_status = client.get("/api/openclaw/status")
sessions = client.get("/api/openclaw/sessions")
session = client.get("/api/openclaw/sessions/main/session-1")
history = client.get("/api/openclaw/sessions/main/session-1/history", params={"limit": 5})
cron = client.get("/api/openclaw/cron")
approvals = client.get("/api/openclaw/approvals")
assert health.status_code == 200
assert health.json()["service"] == "openclaw-service"
assert status.status_code == 200
assert status.json()["status"] == "operational"
assert openclaw_status.status_code == 200
assert openclaw_status.json()["runtimeVersion"] == "2026.3.24"
assert sessions.status_code == 200
assert len(sessions.json()["sessions"]) == 2
assert session.status_code == 200
assert session.json()["session"]["agentId"] == "main"
assert history.status_code == 200
assert history.json()["limit"] == 5
assert cron.status_code == 200
assert cron.json()["jobs"][0]["id"] == "job-1"
assert approvals.status_code == 200
assert approvals.json()["approvals"][0]["id"] == "ap-1"
def test_openclaw_service_session_404():
app = create_app()
app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = (
lambda: _FakeOpenClawCliService()
)
with TestClient(app) as client:
response = client.get("/api/openclaw/sessions/missing")
assert response.status_code == 404

View File

@@ -0,0 +1,532 @@
import { useEffect, useState } from "react";
import { useOpenClawStore } from "../store/openclawStore";
import { useOpenClawPanel } from "../hooks/useOpenClawPanel";
// Agent run states matching openclaw-control-center/src/types.ts
const AGENT_RUN_STATES = {
idle: { label: "空闲", color: "#9CA3AF" },
running: { label: "运行中", color: "#10B981" },
blocked: { label: "阻塞", color: "#F59E0B" },
waiting_approval: { label: "待审批", color: "#8B5CF6" },
error: { label: "错误", color: "#EF4444" },
};
// Agent accent colors for avatar borders
const AGENT_COLORS = [
{ accent: "#3B82F6" },
{ accent: "#8B5CF6" },
{ accent: "#EC4899" },
{ accent: "#F59E0B" },
{ accent: "#10B981" },
{ accent: "#EF4444" },
{ accent: "#06B6D4" },
{ accent: "#84CC16" },
];
function getAgentColor(agentId) {
let hash = 0;
for (let i = 0; i < (agentId || "").length; i++) {
hash = ((hash << 5) - hash) + agentId.charCodeAt(i);
hash = hash & hash;
}
return AGENT_COLORS[Math.abs(hash) % AGENT_COLORS.length].accent;
}
function agentStateFromPresence(presence, agentId) {
const p = presence?.[agentId];
if (!p) return "idle";
if (p.status === "active") return "running";
if (p.sessions?.some(s => s.state === "blocked")) return "blocked";
if (p.sessions?.some(s => s.state === "waiting_approval")) return "waiting_approval";
if (p.sessions?.some(s => s.state === "error" || s.state === "failed")) return "error";
if (p.activeSessions > 0) return "running";
return "idle";
}
function AvatarIcon({ agentId, size = 56, borderRadius = 14 }) {
const color = getAgentColor(agentId);
return (
<div style={{
width: size,
height: size,
borderRadius,
background: `${color}18`,
border: `1px solid ${color}33`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: size * 0.36,
fontWeight: 800,
color,
}}>
{agentId?.slice(0, 2).toUpperCase() || "??"}
</div>
);
}
function SkillBadge({ skill, color }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{
display: "grid",
gap: 7,
paddingBottom: 10,
borderBottom: "1px dashed #D7DEE7",
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "flex-start" }}>
<button
type="button"
onClick={() => setExpanded(!expanded)}
style={{
flex: 1,
minWidth: 0,
border: "none",
background: "transparent",
padding: 0,
textAlign: "left",
cursor: "pointer",
display: "grid",
gap: 4,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#6B7280", fontWeight: 700 }}>
{expanded ? "▾" : "▸"}
</span>
{skill.emoji && <span style={{ fontSize: 12 }}>{skill.emoji}</span>}
<span style={{ fontSize: 12, fontWeight: 700, color: "#111111" }}>
{skill.name || "未命名技能"}
</span>
</div>
</button>
</div>
{expanded && skill.description && (
<div style={{
marginLeft: 20,
borderRadius: 8,
border: "1px solid #E5E7EB",
background: "#FFFFFF",
padding: "10px 12px",
display: "grid",
gap: 8,
}}>
<div style={{
fontSize: 11,
color: "#1F2937",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
fontFamily: '"Courier New", monospace',
}}>
{skill.description}
</div>
</div>
)}
</div>
);
}
function AgentDetail({ agent, presence, skills }) {
const { workspaceFiles, workspaceFilesLoading, workspaceFilesError, workspaceFileContent } = useOpenClawStore();
const { requestWorkspaceFiles, requestWorkspaceFile } = useOpenClawPanel();
const [selectedDoc, setSelectedDoc] = useState(null);
// Always use "main" as the workspace key since that's the only valid OpenClaw agent ID
const workspace = agent?.id || "main";
const rawFiles = workspaceFiles[workspace]?.files || [];
// Normalize file props: API returns uppercase (Name, Size, Path, Preview, PreviewTruncated)
const files = rawFiles.map(f => ({
name: f.Name || f.name,
size: f.Size || f.size,
path: f.Path || f.path,
preview: f.Preview || f.preview,
previewTruncated: f.PreviewTruncated || f.previewTruncated,
}));
const isLoadingFiles = workspaceFilesLoading && !workspaceFiles[workspace];
// Fetch workspace files when agent changes
useEffect(() => {
if (workspace && !workspaceFiles[workspace]) {
requestWorkspaceFiles(workspace);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace]);
const agentId = agent.id || agent.name || "?";
const state = agentStateFromPresence(presence, agentId);
const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle;
const color = getAgentColor(agentId);
// Skills are global in OpenClaw — show all skills (not filtered per-agent)
return (
<div style={{
display: "grid",
gap: 16,
minHeight: 0,
overflowY: "auto",
alignContent: "start",
}}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<AvatarIcon agentId={agentId} size={58} borderRadius={12} />
<div style={{ display: "grid", gap: 4 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: "#111111" }}>{agent.name || agentId}</div>
<div style={{ fontSize: 11, color: "#6B7280" }}>{agentId}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: stateInfo.color }} />
<span style={{ fontSize: 11, color: stateInfo.color, fontWeight: 700 }}>{stateInfo.label}</span>
</div>
</div>
</div>
<div style={{
border: `1px solid ${color}2e`,
background: `${color}0e`,
borderRadius: 12,
padding: "10px 12px",
display: "flex",
alignItems: "center",
gap: 10,
}}>
<div style={{ display: "grid", gap: 2 }}>
<div style={{ fontSize: 11, color: "#4B5563", fontWeight: 700 }}>模型</div>
<div style={{ fontSize: 12, color: "#111111", fontWeight: 800, fontFamily: '"Courier New", monospace' }}>
{agent.model || "—"}
</div>
</div>
</div>
</div>
{/* Skills + Documents: left-right layout */}
<div style={{
display: "grid",
gridTemplateColumns: "minmax(240px, 2fr) minmax(0, 3fr)",
gap: 16,
alignItems: "start",
minHeight: 0,
}}>
{/* Left: Skills */}
<div style={{ display: "grid", gap: 10 }}>
{(() => {
const available = skills.filter(s => {
const hasMissing = s.missing && (s.missing.bins?.length || s.missing.env?.length || s.missing.config?.length);
return s.eligible !== false && s.disabled !== true && !hasMissing;
});
return available.length > 0 && (
<div style={{
border: "1px solid #E5EAF1",
borderRadius: 12,
background: "#FCFDFE",
padding: 14,
display: "grid",
gap: 10,
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
<div style={{ display: "grid", gap: 2 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: "#111111" }}>可用技能</div>
<div style={{ fontSize: 10, color: "#6B7280", fontFamily: '"Courier New", monospace' }}>
已就绪: {available.length}
</div>
</div>
</div>
<div style={{
border: "1px solid #E5E7EB",
background: "#F8FAFC",
borderRadius: 8,
padding: "10px 12px",
display: "grid",
gap: 8,
maxHeight: 420,
overflowY: "auto",
}}>
{available.map((skill, i) => (
<SkillBadge key={skill.name || i} skill={skill} color={color} />
))}
</div>
</div>
);
})()}
</div>
{/* Right: Documents */}
<div style={{ display: "grid", gap: 10 }}>
{workspace && (
<div style={{
border: "1px solid #E5EAF1",
borderRadius: 12,
background: "#FCFDFE",
padding: 14,
display: "grid",
gap: 10,
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "center" }}>
<div style={{ display: "grid", gap: 2 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: "#111111" }}>工作区文档</div>
<div style={{ fontSize: 10, color: "#6B7280", fontFamily: '"Courier New", monospace' }}>
{files.length} 个文件
</div>
</div>
</div>
<div style={{
border: "1px solid #E5E7EB",
background: "#F8FAFC",
borderRadius: 8,
padding: "10px 12px",
display: "grid",
gap: 8,
maxHeight: 420,
overflowY: "auto",
}}>
{isLoadingFiles ? (
<div style={{ fontSize: 11, color: "#6B7280", fontStyle: "italic" }}>加载中</div>
) : workspaceFilesError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>加载失败</div>
) : files.length === 0 ? (
<div style={{ fontSize: 11, color: "#9CA3AF" }}>暂无文档</div>
) : (
files.map((f) => (
<button
key={f.name}
type="button"
onClick={() => {
const clickedFile = selectedDoc?.name === f.name ? null : f;
setSelectedDoc(clickedFile);
if (clickedFile && !workspaceFileContent[`${workspace}:${f.name}`]) {
requestWorkspaceFile(workspace, f.name);
}
}}
style={{
textAlign: "left",
background: selectedDoc?.name === f.name ? `${color}14` : "transparent",
border: `1px solid ${selectedDoc?.name === f.name ? color + "40" : "#E5EAF1"}`,
borderRadius: 6,
padding: "8px 10px",
cursor: "pointer",
display: "grid",
gap: 4,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontSize: 11, fontWeight: 700, color: "#111111" }}>{f.name}</div>
<div style={{ fontSize: 10, color: "#9CA3AF" }}>{f.size} B</div>
</div>
{selectedDoc?.name === f.name && (
<div style={{
fontSize: 10,
color: "#4B5563",
fontFamily: '"Courier New", monospace',
lineHeight: 1.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 120,
overflowY: "auto",
background: "#FFFFFF",
borderRadius: 4,
padding: "6px 8px",
marginTop: 4,
}}>
{workspaceFileContent[`${workspace}:${f.name}`] || f.preview || "(内容加载中...)"}
</div>
)}
</button>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function OpenClawStatus() {
const store = useOpenClawStore();
const {
requestStatus,
requestAgents,
requestAgentsPresence,
requestSkills,
} = useOpenClawPanel();
const [selectedAgentId, setSelectedAgentId] = useState(
() => store.agents[0]?.id || store.agents[0]?.name || null
);
// Fetch data only if store is empty (on mount / page refresh)
useEffect(() => {
if (!store.agents.length) requestAgents();
if (!store.skills.length) requestSkills();
if (!store.openclawStatus) requestStatus();
requestAgentsPresence();
}, []);
const status = store.openclawStatus;
const agents = store.agents;
const presence = store.agentsPresence?.agents || {};
const skills = store.skills || [];
const selectedAgent = agents.find(a => (a.id || a.name) === selectedAgentId) || agents[0] || null;
// Auto-select first agent when agents load
useEffect(() => {
if (!selectedAgentId && agents.length > 0) {
setSelectedAgentId(agents[0].id || agents[0].name);
}
}, [agents, selectedAgentId]);
return (
<div style={{
height: "100%",
overflow: "hidden",
padding: "18px",
background: "linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)",
display: "grid",
gridTemplateRows: "auto 1fr",
gap: 18,
}}>
{/* Header */}
<div style={{ display: "grid", gap: 4 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.5px", color: "#111111" }}>
OpenClaw Agent 状态
</div>
<div style={{ fontSize: 11, color: "#6B7280" }}>
监控 OpenClaw Agent 运行时状态
</div>
</div>
{/* Main content: left agent list + right detail */}
<div style={{
display: "grid",
gridTemplateColumns: agents.length > 0 ? "120px minmax(0, 1fr)" : "1fr",
gap: 16,
alignItems: "stretch",
minHeight: 0,
overflow: "hidden",
}}>
{/* Left: agent avatar list */}
{agents.length > 0 && (
<div style={{
border: "1px solid #D9E0E7",
borderRadius: 14,
background: "#FFFFFF",
boxShadow: "0 10px 24px rgba(15, 23, 42, 0.06)",
padding: 12,
display: "grid",
gap: 10,
minHeight: 0,
overflowY: "auto",
alignContent: "start",
}}>
{agents.map((agent) => {
const agentId = agent.id || agent.name;
const isSelected = (agent.id || agent.name) === (selectedAgent?.id || selectedAgent?.name);
const color = getAgentColor(agentId);
const state = agentStateFromPresence(presence, agentId);
const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle;
return (
<button
key={agentId}
type="button"
onClick={() => setSelectedAgentId(agentId)}
title={agent.name || agentId}
style={{
border: isSelected ? `2px solid ${color}` : "1px solid #D9E0E7",
borderRadius: 16,
background: isSelected ? `${color}10` : "#FFFFFF",
boxShadow: isSelected ? `0 10px 20px ${color}18` : "none",
padding: 8,
display: "grid",
gap: 4,
justifyItems: "center",
cursor: "pointer",
}}
>
<div style={{ position: "relative" }}>
<AvatarIcon agentId={agentId} size={48} borderRadius={12} />
<div style={{
position: "absolute",
bottom: -2,
right: -2,
width: 10,
height: 10,
borderRadius: "50%",
background: stateInfo.color,
border: "2px solid #FFFFFF",
}} />
</div>
<div style={{
fontSize: 9,
fontWeight: 800,
color: isSelected ? color : "#374151",
textAlign: "center",
lineHeight: 1.4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}>
{agent.name || agentId}
</div>
</button>
);
})}
</div>
)}
{/* Right: agent detail */}
<div style={{
display: "grid",
minHeight: 0,
overflow: "hidden",
}}>
{/* Agent detail */}
<div style={{
border: "1px solid #D9E0E7",
borderRadius: 14,
background: "#FFFFFF",
boxShadow: "0 10px 24px rgba(15, 23, 42, 0.06)",
padding: 18,
display: "grid",
minHeight: 0,
overflow: "hidden",
}}>
{agents.length === 0 ? (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 12,
}}>
<AvatarIcon agentId="??" size={64} borderRadius={16} />
<div style={{ fontSize: 13, color: "#9CA3AF" }}>
{store.agentsLoading ? "加载中..." : (store.agentsError ? `错误: ${String(store.agentsError).slice(0, 60)}` : "暂无 Agent")}
</div>
<button
onClick={() => { requestAgents(); requestAgentsPresence(); }}
style={{
padding: "8px 14px",
border: "1px solid #000000",
background: "#FFFFFF",
color: "#000000",
fontSize: 11,
fontWeight: 700,
borderRadius: 8,
cursor: "pointer",
}}
>
刷新
</button>
</div>
) : selectedAgent ? (
<AgentDetail agent={selectedAgent} presence={presence} skills={skills} />
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { OpenClawStatus } from './OpenClawStatus';
export default function OpenClawView() {
return (
<div style={{
height: '100%',
overflow: 'auto',
padding: '16px',
background: '#F3F4F6',
}}>
<OpenClawStatus />
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { useCallback } from "react";
import { useOpenClawStore } from "../store/openclawStore";
const RETRY_DELAY_MS = 250;
function sendWithRetry(clientRef, payload, retries = 3) {
const attemptSend = (remaining) => {
const client = clientRef.current;
if (!client) return false;
const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload));
if (sent || remaining <= 0) return sent;
window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS);
return false;
};
return attemptSend(retries);
}
export function useOpenClawPanel() {
// Access store state directly — do NOT destructure store as a useCallback dep
// or every store update will recreate all callbacks and trigger infinite loops.
const getStore = () => useOpenClawStore.getState();
const requestStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setStatusLoading(true);
store.setStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_status" });
}, []);
const requestSessions = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSessionsLoading(true);
store.setSessionsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_sessions" });
}, []);
const requestSessionDetail = useCallback((sessionKey) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSelectedSessionKey(sessionKey);
store.setSessionDetailLoading(true);
store.setSessionDetailError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey });
}, []);
const requestSessionHistory = useCallback((sessionKey, limit = 20) => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, {
type: "get_openclaw_session_history",
session_key: sessionKey,
limit,
});
}, []);
const requestCron = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setCronLoading(true);
store.setCronError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_cron" });
}, []);
const requestApprovals = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setApprovalsLoading(true);
store.setApprovalsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_approvals" });
}, []);
const requestAgents = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setAgentsLoading(true);
store.setAgentsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_agents" });
}, []);
const requestAgentsPresence = useCallback(() => {
const client = getStore().clientRef?.current;
if (!client) return;
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
}, []);
const requestSkills = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillsLoading(true);
store.setSkillsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skills" });
}, []);
const requestModels = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsLoading(true);
store.setModelsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models" });
}, []);
const requestHooks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setHooksLoading(true);
store.setHooksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_hooks" });
}, []);
const requestPlugins = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPluginsLoading(true);
store.setPluginsError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_plugins" });
}, []);
const requestSecretsAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecretsAuditLoading(true);
store.setSecretsAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" });
}, []);
const requestSecurityAudit = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSecurityAuditLoading(true);
store.setSecurityAuditError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" });
}, []);
const requestDaemonStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setDaemonStatusLoading(true);
store.setDaemonStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" });
}, []);
const requestPairing = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setPairingLoading(true);
store.setPairingError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_pairing" });
}, []);
const requestQrCode = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setQrCodeLoading(true);
store.setQrCodeError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_qr" });
}, []);
const requestUpdateStatus = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setUpdateStatusLoading(true);
store.setUpdateStatusError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_update_status" });
}, []);
const requestModelsAliases = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsAliasesLoading(true);
store.setModelsAliasesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" });
}, []);
const requestModelsFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsFallbacksLoading(true);
store.setModelsFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" });
}, []);
const requestModelsImageFallbacks = useCallback(() => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setModelsImageFallbacksLoading(true);
store.setModelsImageFallbacksError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" });
}, []);
const requestSkillUpdate = useCallback((slug = null, all = false) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client) return;
store.setSkillUpdateLoading(true);
store.setSkillUpdateError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all });
}, []);
const requestWorkspaceFiles = useCallback((workspace) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !workspace) return;
store.setWorkspaceFilesLoading(true);
store.setWorkspaceFilesError(null);
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace });
}, []);
const requestWorkspaceFile = useCallback((agent_id, file_name) => {
const store = getStore();
const client = store.clientRef?.current;
if (!client || !agent_id || !file_name) return;
console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name });
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name });
}, []);
return {
requestStatus,
requestSessions,
requestSessionDetail,
requestSessionHistory,
requestCron,
requestApprovals,
requestAgents,
requestAgentsPresence,
requestSkills,
requestModels,
requestHooks,
requestPlugins,
requestSecretsAudit,
requestSecurityAudit,
requestDaemonStatus,
requestPairing,
requestQrCode,
requestUpdateStatus,
requestModelsAliases,
requestModelsFallbacks,
requestModelsImageFallbacks,
requestSkillUpdate,
requestWorkspaceFiles,
requestWorkspaceFile,
};
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { AGENTS } from '../config/constants'; import { AGENTS } from '../config/constants';
import { ReadOnlyClient } from '../services/websocket'; import { ReadOnlyClient } from '../services/websocket';
import { useRuntimeStore } from '../store/runtimeStore'; import { useRuntimeStore } from '../store/runtimeStore';
import { useOpenClawStore } from '../store/openclawStore';
import { useMarketStore } from '../store/marketStore'; import { useMarketStore } from '../store/marketStore';
import { usePortfolioStore } from '../store/portfolioStore'; import { usePortfolioStore } from '../store/portfolioStore';
import { useAgentStore } from '../store/agentStore'; import { useAgentStore } from '../store/agentStore';
@@ -797,7 +798,198 @@ export function useWebSocketConnection({
fast_forward_success: (e) => { fast_forward_success: (e) => {
console.log(`${e.message}`); console.log(`${e.message}`);
},
openclaw_status_loaded: (e) => {
useOpenClawStore.getState().setOpenclawStatus(e.data || e);
useOpenClawStore.getState().setStatusLoading(false);
},
openclaw_sessions_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessions(e.data || e);
useOpenClawStore.getState().setSessionsLoading(false);
},
openclaw_session_detail_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessionDetail(e.data || e);
useOpenClawStore.getState().setSessionDetailLoading(false);
},
openclaw_session_history_loaded: (e) => {
useOpenClawStore.getState().setOpenclawSessionHistory(e.data || e);
},
openclaw_cron_loaded: (e) => {
useOpenClawStore.getState().setOpenclawCronJobs(e.data || e);
useOpenClawStore.getState().setCronLoading(false);
},
openclaw_approvals_loaded: (e) => {
useOpenClawStore.getState().setOpenclawApprovals(e.data || e);
useOpenClawStore.getState().setApprovalsLoading(false);
},
openclaw_agents_loaded: (e) => {
useOpenClawStore.getState().setAgentsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setAgentsError(d.error);
} else {
useOpenClawStore.getState().setAgents(d?.agents || []);
useOpenClawStore.getState().setAgentsError(null);
} }
},
openclaw_agents_presence_loaded: (e) => {
useOpenClawStore.getState().setAgentsPresence((e.data?.data ?? e.data) || {});
},
openclaw_skills_loaded: (e) => {
useOpenClawStore.getState().setSkillsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setSkillsError(d.error);
} else {
useOpenClawStore.getState().setSkills(d?.skills || []);
useOpenClawStore.getState().setSkillsError(null);
}
},
openclaw_models_loaded: (e) => {
useOpenClawStore.getState().setModelsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setModelsError(d.error);
} else {
useOpenClawStore.getState().setModels(d?.models || []);
useOpenClawStore.getState().setModelsError(null);
}
},
openclaw_workspace_files_loaded: (e) => {
useOpenClawStore.getState().setWorkspaceFilesLoading(false);
const d = e.data?.data ?? e.data;
const workspace = d?.workspace || "";
if (d?.error) {
useOpenClawStore.getState().setWorkspaceFilesError(d.error);
} else {
useOpenClawStore.getState().setWorkspaceFiles(workspace, d);
useOpenClawStore.getState().setWorkspaceFilesError(null);
}
},
openclaw_workspace_file_loaded: (e) => {
const d = e.data?.data ?? e.data;
console.log("[DEBUG] workspace_file_loaded:", { d });
if (d?.error) return;
const agentId = d?.agentId || "main";
const fileName = d?.file?.Name || d?.file?.name || "";
const key = `${agentId}:${fileName}`;
if (d?.file?.missing) {
useOpenClawStore.getState().setWorkspaceFileContent(key, "(文件不存在)");
} else if (d?.file?.content) {
useOpenClawStore.getState().setWorkspaceFileContent(key, d.file.content);
}
},
openclaw_hooks_loaded: (e) => {
useOpenClawStore.getState().setHooksLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setHooksError(d.error);
} else {
useOpenClawStore.getState().setHooks(d?.hooks || []);
useOpenClawStore.getState().setHooksError(null);
}
},
openclaw_plugins_loaded: (e) => {
useOpenClawStore.getState().setPluginsLoading(false);
const d = e.data?.data ?? e.data;
if (d?.error) {
useOpenClawStore.getState().setPluginsError(d.error);
} else {
useOpenClawStore.getState().setPlugins(d?.plugins || []);
useOpenClawStore.getState().setPluginsError(null);
}
},
openclaw_secrets_audit_loaded: (e) => {
useOpenClawStore.getState().setSecretsAuditLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSecretsAuditError(e.data.data.error);
} else {
useOpenClawStore.getState().setSecretsAudit(e.data?.data || null);
useOpenClawStore.getState().setSecretsAuditError(null);
}
},
openclaw_security_audit_loaded: (e) => {
useOpenClawStore.getState().setSecurityAuditLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSecurityAuditError(e.data.data.error);
} else {
useOpenClawStore.getState().setSecurityAudit(e.data?.data || null);
useOpenClawStore.getState().setSecurityAuditError(null);
}
},
openclaw_daemon_status_loaded: (e) => {
useOpenClawStore.getState().setDaemonStatusLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setDaemonStatusError(e.data.data.error);
} else {
useOpenClawStore.getState().setDaemonStatus(e.data?.data || null);
useOpenClawStore.getState().setDaemonStatusError(null);
}
},
openclaw_pairing_loaded: (e) => {
useOpenClawStore.getState().setPairingLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setPairingError(e.data.data.error);
} else {
useOpenClawStore.getState().setPairing(e.data?.data || null);
useOpenClawStore.getState().setPairingError(null);
}
},
openclaw_qr_loaded: (e) => {
useOpenClawStore.getState().setQrCodeLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setQrCodeError(e.data.data.error);
} else {
useOpenClawStore.getState().setQrCode(e.data?.data || null);
useOpenClawStore.getState().setQrCodeError(null);
}
},
openclaw_update_status_loaded: (e) => {
useOpenClawStore.getState().setUpdateStatusLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setUpdateStatusError(e.data.data.error);
} else {
useOpenClawStore.getState().setUpdateStatus(e.data?.data || null);
useOpenClawStore.getState().setUpdateStatusError(null);
}
},
openclaw_models_aliases_loaded: (e) => {
useOpenClawStore.getState().setModelsAliasesLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsAliasesError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsAliases(e.data?.data || null);
useOpenClawStore.getState().setModelsAliasesError(null);
}
},
openclaw_models_fallbacks_loaded: (e) => {
useOpenClawStore.getState().setModelsFallbacksLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsFallbacksError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsFallbacks(e.data?.data?.items || []);
useOpenClawStore.getState().setModelsFallbacksError(null);
}
},
openclaw_models_image_fallbacks_loaded: (e) => {
useOpenClawStore.getState().setModelsImageFallbacksLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setModelsImageFallbacksError(e.data.data.error);
} else {
useOpenClawStore.getState().setModelsImageFallbacks(e.data?.data?.items || []);
useOpenClawStore.getState().setModelsImageFallbacksError(null);
}
},
openclaw_skill_update_loaded: (e) => {
useOpenClawStore.getState().setSkillUpdateLoading(false);
if (e.data?.data?.error) {
useOpenClawStore.getState().setSkillUpdateError(e.data.data.error);
} else {
useOpenClawStore.getState().setSkillUpdate(e.data?.data || null);
useOpenClawStore.getState().setSkillUpdateError(null);
}
},
}; };
try { try {

View File

@@ -0,0 +1,227 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useOpenClawStore = create(
persist(
(set) => ({
// Raw data
openclawStatus: null,
openclawSessions: [],
openclawSessionDetail: null,
openclawSessionHistory: [],
openclawCronJobs: [],
openclawApprovals: [],
// Loading states
isStatusLoading: false,
isSessionsLoading: false,
isSessionDetailLoading: false,
isCronLoading: false,
isApprovalsLoading: false,
// Error states
statusError: null,
sessionsError: null,
sessionDetailError: null,
cronError: null,
approvalsError: null,
// Agents state
agents: [],
agentsLoading: false,
agentsError: null,
agentsPresence: {},
// Skills state
skills: [],
skillsLoading: false,
skillsError: null,
// Models state
models: [],
modelsLoading: false,
modelsError: null,
// Hooks state
hooks: [],
hooksLoading: false,
hooksError: null,
// Plugins state
plugins: [],
pluginsLoading: false,
pluginsError: null,
// Secrets audit state
secretsAudit: null,
secretsAuditLoading: false,
secretsAuditError: null,
// Security audit state
securityAudit: null,
securityAuditLoading: false,
securityAuditError: null,
// Daemon status state
daemonStatus: null,
daemonStatusLoading: false,
daemonStatusError: null,
// Pairing state
pairing: null,
pairingLoading: false,
pairingError: null,
// QR code state
qrCode: null,
qrCodeLoading: false,
qrCodeError: null,
// Update status state
updateStatus: null,
updateStatusLoading: false,
updateStatusError: null,
// Models aliases state
modelsAliases: null,
modelsAliasesLoading: false,
modelsAliasesError: null,
// Models fallbacks state
modelsFallbacks: [],
modelsFallbacksLoading: false,
modelsFallbacksError: null,
// Models image fallbacks state
modelsImageFallbacks: [],
modelsImageFallbacksLoading: false,
modelsImageFallbacksError: null,
// Skill update state
skillUpdate: null,
skillUpdateLoading: false,
skillUpdateError: null,
// Workspace files state (per agent, keyed by workspace path)
workspaceFiles: {},
workspaceFilesLoading: false,
workspaceFilesError: null,
// Workspace file content (keyed by "agentId:filename")
workspaceFileContent: {},
// Selected session key for detail/history drill-down
selectedSessionKey: null,
// WebSocket client ref (set by App.jsx on connection)
clientRef: null,
setClientRef: (ref) => set({ clientRef: ref }),
// Setters
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
setOpenclawSessions: (data) => set({ openclawSessions: data?.sessions || [], sessionsError: null }),
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: null }),
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: null }),
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
setSelectedSessionKey: (key) => set({ selectedSessionKey: key }),
setStatusLoading: (v) => set({ isStatusLoading: v }),
setSessionsLoading: (v) => set({ isSessionsLoading: v }),
setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }),
setCronLoading: (v) => set({ isCronLoading: v }),
setApprovalsLoading: (v) => set({ isApprovalsLoading: v }),
setStatusError: (e) => set({ statusError: e }),
setSessionsError: (e) => set({ sessionsError: e }),
setSessionDetailError: (e) => set({ sessionDetailError: e }),
setCronError: (e) => set({ cronError: e }),
setApprovalsError: (e) => set({ approvalsError: e }),
setAgents: (agents) => set({ agents }),
setAgentsLoading: (loading) => set({ agentsLoading: loading }),
setAgentsError: (error) => set({ agentsError: error }),
setAgentsPresence: (presence) => set({ agentsPresence: presence }),
setSkills: (skills) => set({ skills }),
setSkillsLoading: (loading) => set({ skillsLoading: loading }),
setSkillsError: (error) => set({ skillsError: error }),
setModels: (models) => set({ models }),
setModelsLoading: (loading) => set({ modelsLoading: loading }),
setModelsError: (error) => set({ modelsError: error }),
setHooks: (hooks) => set({ hooks }),
setHooksLoading: (loading) => set({ hooksLoading: loading }),
setHooksError: (error) => set({ hooksError: error }),
setPlugins: (plugins) => set({ plugins }),
setPluginsLoading: (loading) => set({ pluginsLoading: loading }),
setPluginsError: (error) => set({ pluginsError: error }),
setSecretsAudit: (data) => set({ secretsAudit: data }),
setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }),
setSecretsAuditError: (error) => set({ secretsAuditError: error }),
setSecurityAudit: (data) => set({ securityAudit: data }),
setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }),
setSecurityAuditError: (error) => set({ securityAuditError: error }),
setDaemonStatus: (data) => set({ daemonStatus: data }),
setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }),
setDaemonStatusError: (error) => set({ daemonStatusError: error }),
setPairing: (data) => set({ pairing: data }),
setPairingLoading: (loading) => set({ pairingLoading: loading }),
setPairingError: (error) => set({ pairingError: error }),
setQrCode: (data) => set({ qrCode: data }),
setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }),
setQrCodeError: (error) => set({ qrCodeError: error }),
setUpdateStatus: (data) => set({ updateStatus: data }),
setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }),
setUpdateStatusError: (error) => set({ updateStatusError: error }),
setModelsAliases: (data) => set({ modelsAliases: data }),
setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }),
setModelsAliasesError: (error) => set({ modelsAliasesError: error }),
setModelsFallbacks: (data) => set({ modelsFallbacks: data }),
setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }),
setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }),
setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }),
setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }),
setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }),
setSkillUpdate: (data) => set({ skillUpdate: data }),
setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }),
setSkillUpdateError: (error) => set({ skillUpdateError: error }),
setWorkspaceFiles: (workspace, data) => set((state) => ({
workspaceFiles: { ...state.workspaceFiles, [workspace]: data },
})),
setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }),
setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }),
setWorkspaceFileContent: (key, content) => set((state) => ({
workspaceFileContent: { ...state.workspaceFileContent, [key]: content },
})),
}),
{
name: "openclaw-store",
// Skip persisting ephemeral UI state
partialize: (state) => ({
// Persist only data, not loading/error/UI states
openclawStatus: state.openclawStatus,
openclawSessions: state.openclawSessions,
openclawCronJobs: state.openclawCronJobs,
openclawApprovals: state.openclawApprovals,
agents: state.agents,
agentsPresence: state.agentsPresence,
skills: state.skills,
models: state.models,
hooks: state.hooks,
plugins: state.plugins,
secretsAudit: state.secretsAudit,
securityAudit: state.securityAudit,
daemonStatus: state.daemonStatus,
pairing: state.pairing,
qrCode: state.qrCode,
updateStatus: state.updateStatus,
modelsAliases: state.modelsAliases,
modelsFallbacks: state.modelsFallbacks,
modelsImageFallbacks: state.modelsImageFallbacks,
skillUpdate: state.skillUpdate,
}),
}
)
);

View File

@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
"""OpenClaw service client — typed async wrapper for openclaw-service REST API."""
from __future__ import annotations
import httpx
from shared.models.openclaw import (
AgentsList,
ApprovalRequest,
ApprovalsList,
CronJob,
CronList,
DaemonStatus,
HookStatusReport,
ModelAliasesList,
ModelFallbacksList,
ModelsList,
OpenClawStatus,
PairingListResponse,
QrCodeResponse,
SecretsAuditReport,
SecurityAuditResponse,
SessionEntry,
SessionHistory,
SessionsList,
SkillStatusReport,
SkillUpdateResult,
UpdateStatusResponse,
normalize_agents,
normalize_approvals,
normalize_cron_jobs,
normalize_daemon_status,
normalize_hooks,
normalize_model_aliases,
normalize_model_fallbacks,
normalize_models,
normalize_pairing,
normalize_qr,
normalize_security_audit,
normalize_secrets_audit,
normalize_session_history,
normalize_sessions,
normalize_skill_update,
normalize_skills,
normalize_status,
normalize_update_status,
normalize_plugins,
PluginsList,
)
class OpenClawServiceClient:
"""Async client for the openclaw-service API surface.
All methods return typed Pydantic models. The raw JSON dict is
accessible via the `.model_dump()` method on each result.
Example::
async with OpenClawServiceClient() as client:
status = await client.fetch_status()
print(status.runtime_version) # typed
print(status.model_dump()["runtimeVersion"]) # raw dict
"""
def __init__(self, base_url: str = "http://localhost:8004/api/openclaw"):
self.base_url = base_url.rstrip("/")
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "OpenClawServiceClient":
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
if self._client:
await self._client.aclose()
async def fetch_status(self) -> OpenClawStatus:
"""GET /status — returns parsed OpenClawStatus model."""
response = await self._client.get("/status")
response.raise_for_status()
raw = response.json()
return normalize_status(raw)
async def list_sessions(self) -> SessionsList:
"""GET /sessions — returns parsed SessionsList model."""
response = await self._client.get("/sessions")
response.raise_for_status()
raw = response.json()
return normalize_sessions(raw)
async def get_session(self, session_key: str) -> SessionEntry:
"""GET /sessions/{session_key} — returns parsed SessionEntry model."""
response = await self._client.get(f"/sessions/{session_key}")
response.raise_for_status()
raw = response.json()
session = raw.get("session") or {}
return SessionEntry.model_validate(session, strict=False)
async def get_session_history(self, session_key: str, *, limit: int = 20) -> SessionHistory:
"""GET /sessions/{session_key}/history — returns parsed SessionHistory model."""
response = await self._client.get(
f"/sessions/{session_key}/history",
params={"limit": limit},
)
response.raise_for_status()
raw = response.json()
return normalize_session_history(raw, session_key=session_key)
async def list_cron_jobs(self) -> CronList:
"""GET /cron — returns parsed CronList model."""
response = await self._client.get("/cron")
response.raise_for_status()
raw = response.json()
return normalize_cron_jobs(raw)
async def list_approvals(self) -> ApprovalsList:
"""GET /approvals — returns parsed ApprovalsList model."""
response = await self._client.get("/approvals")
response.raise_for_status()
raw = response.json()
return normalize_approvals(raw)
async def list_agents(self) -> AgentsList:
"""GET /agents — returns parsed AgentsList model."""
response = await self._client.get("/agents")
response.raise_for_status()
raw = response.json()
return normalize_agents(raw)
async def list_skills(self) -> SkillStatusReport:
"""GET /skills — returns parsed SkillStatusReport model."""
response = await self._client.get("/skills")
response.raise_for_status()
raw = response.json()
return normalize_skills(raw)
async def list_models(self) -> ModelsList:
"""GET /models — returns parsed ModelsList model."""
response = await self._client.get("/models")
response.raise_for_status()
raw = response.json()
return normalize_models(raw)
async def list_hooks(self) -> HookStatusReport:
response = await self._client.get("/hooks")
response.raise_for_status()
raw = response.json()
return normalize_hooks(raw)
async def list_plugins(self) -> PluginsList:
response = await self._client.get("/plugins")
response.raise_for_status()
raw = response.json()
return normalize_plugins(raw)
async def secrets_audit(self) -> SecretsAuditReport:
response = await self._client.get("/secrets-audit")
response.raise_for_status()
raw = response.json()
return normalize_secrets_audit(raw)
async def security_audit(self) -> SecurityAuditResponse:
response = await self._client.get("/security-audit")
response.raise_for_status()
raw = response.json()
return normalize_security_audit(raw)
async def daemon_status(self) -> DaemonStatus:
response = await self._client.get("/daemon-status")
response.raise_for_status()
raw = response.json()
return normalize_daemon_status(raw)
async def pairing_list(self) -> PairingListResponse:
response = await self._client.get("/pairing")
response.raise_for_status()
raw = response.json()
return normalize_pairing(raw)
async def qr_code(self) -> QrCodeResponse:
response = await self._client.get("/qr")
response.raise_for_status()
raw = response.json()
return normalize_qr(raw)
async def update_status(self) -> UpdateStatusResponse:
response = await self._client.get("/update-status")
response.raise_for_status()
raw = response.json()
return normalize_update_status(raw)
async def list_model_aliases(self) -> ModelAliasesList:
response = await self._client.get("/models-aliases")
response.raise_for_status()
raw = response.json()
return normalize_model_aliases(raw)
async def list_model_fallbacks(self) -> ModelFallbacksList:
response = await self._client.get("/models-fallbacks")
response.raise_for_status()
raw = response.json()
return normalize_model_fallbacks(raw)
async def list_model_image_fallbacks(self) -> ModelFallbacksList:
response = await self._client.get("/models-image-fallbacks")
response.raise_for_status()
raw = response.json()
return normalize_model_fallbacks(raw)
async def skill_update(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult:
params = {}
if slug is not None:
params["slug"] = slug
if all:
params["all"] = "true"
response = await self._client.get("/skill-update", params=params)
response.raise_for_status()
raw = response.json()
return normalize_skill_update(raw)
async def models_status(self, *, probe: bool = False) -> dict[str, Any]:
"""GET /models-status — returns parsed models status dict."""
params = {"probe": "true"} if probe else {}
response = await self._client.get("/models-status", params=params)
response.raise_for_status()
return response.json()
async def channels_status(self, *, probe: bool = False) -> dict[str, Any]:
"""GET /channels-status — returns parsed channels status dict."""
params = {"probe": "true"} if probe else {}
response = await self._client.get("/channels-status", params=params)
response.raise_for_status()
return response.json()
async def channels_list(self) -> dict[str, Any]:
"""GET /channels-list — returns parsed channels list dict."""
response = await self._client.get("/channels-list")
response.raise_for_status()
return response.json()
async def hook_info(self, name: str) -> dict[str, Any]:
"""GET /hooks/info/{name} — returns parsed hook info dict."""
response = await self._client.get(f"/hooks/info/{name}")
response.raise_for_status()
return response.json()
async def hooks_check(self) -> dict[str, Any]:
"""GET /hooks/check — returns parsed hooks check dict."""
response = await self._client.get("/hooks/check")
response.raise_for_status()
return response.json()
async def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]:
"""GET /plugins-inspect — returns parsed plugins inspect dict."""
params: dict[str, Any] = {}
if all:
params["all"] = "true"
elif plugin_id:
params["plugin_id"] = plugin_id
response = await self._client.get("/plugins-inspect", params=params)
response.raise_for_status()
return response.json()
async def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]:
"""GET /agents-bindings — returns parsed agents bindings list dict."""
params: dict[str, Any] = {}
if agent:
params["agent"] = agent
response = await self._client.get("/agents-bindings", params=params)
response.raise_for_status()
return response.json()
async def agents_presence(self) -> dict[str, Any]:
"""GET /agents/presence — returns runtime session presence for all agents."""
response = await self._client.get("/agents/presence")
response.raise_for_status()
return response.json()
async def workspace_files(self, workspace_path: str) -> dict[str, Any]:
"""GET /workspace-files?workspace=<path> — list .md files in a workspace."""
response = await self._client.get("/workspace-files", params={"workspace": workspace_path})
response.raise_for_status()
return response.json()
# -------------------------------------------------------------------------
# Write agents operations
# -------------------------------------------------------------------------
async 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]:
"""POST /agents/add — create a new agent."""
params: dict[str, Any] = {"name": name}
if workspace:
params["workspace"] = workspace
if model:
params["model"] = model
if agent_dir:
params["agent_dir"] = agent_dir
if bind:
params["bind"] = bind
if non_interactive:
params["non_interactive"] = "true"
response = await self._client.post("/agents/add", params=params)
response.raise_for_status()
return response.json()
async def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]:
"""POST /agents/delete/{id} — delete an agent."""
params: dict[str, Any] = {}
if force:
params["force"] = "true"
response = await self._client.post(f"/agents/delete/{id}", params=params)
response.raise_for_status()
return response.json()
async def agents_bind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
) -> dict[str, Any]:
"""POST /agents/bind — add routing bindings to an agent."""
params: dict[str, Any] = {}
if agent:
params["agent"] = agent
if bind:
params["bind"] = bind
response = await self._client.post("/agents/bind", params=params)
response.raise_for_status()
return response.json()
async def agents_unbind(
self,
*,
agent: str | None = None,
bind: list[str] | None = None,
all: bool = False,
) -> dict[str, Any]:
"""POST /agents/unbind — remove routing bindings from an agent."""
params: dict[str, Any] = {}
if agent:
params["agent"] = agent
if bind:
params["bind"] = bind
if all:
params["all"] = "true"
response = await self._client.post("/agents/unbind", params=params)
response.raise_for_status()
return response.json()
async 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]:
"""POST /agents/set-identity — update agent identity."""
params: dict[str, Any] = {}
if agent:
params["agent"] = agent
if workspace:
params["workspace"] = workspace
if identity_file:
params["identity_file"] = identity_file
if name:
params["name"] = name
if emoji:
params["emoji"] = emoji
if theme:
params["theme"] = theme
if avatar:
params["avatar"] = avatar
if from_identity:
params["from_identity"] = "true"
response = await self._client.post("/agents/set-identity", params=params)
response.raise_for_status()
return response.json()
async def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]:
"""GET /gateway-status — returns parsed gateway status dict."""
params: dict[str, Any] = {}
if url:
params["url"] = url
if token:
params["token"] = token
response = await self._client.get("/gateway-status", params=params)
response.raise_for_status()
return response.json()
async def memory_status(self, *, agent: str | None = None, deep: bool = False) -> list[dict[str, Any]]:
"""GET /memory-status — returns list of per-agent memory status dicts."""
params: dict[str, Any] = {}
if agent:
params["agent"] = agent
if deep:
params["deep"] = "true"
response = await self._client.get("/memory-status", params=params)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,739 @@
# -*- coding: utf-8 -*-
"""OpenClaw Gateway WebSocket client for bidirectional agent communication."""
from __future__ import annotations
import asyncio
import json
import logging
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import httpx
import websockets
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
# Default Gateway port
DEFAULT_GATEWAY_PORT = 18789
DEFAULT_GATEWAY_URL = f"ws://127.0.0.1:{DEFAULT_GATEWAY_PORT}"
# Protocol version (from protocol/schema/protocol-schemas.ts)
PROTOCOL_VERSION = 3
@dataclass
class DeviceIdentity:
"""Device identity for Gateway authentication."""
device_id: str
public_key_pem: bytes
private_key_pem: bytes
@classmethod
def load_or_create(cls, identity_dir: Path | None = None) -> "DeviceIdentity":
"""Load existing device identity from OpenClaw's identity directory, or create a new one."""
if identity_dir is None:
identity_dir = Path.home() / ".openclaw" / "identity"
device_json = identity_dir / "device.json"
# Check if identity exists in OpenClaw's format
if device_json.exists():
import json
data = json.loads(device_json.read_text())
return cls(
device_id=data["deviceId"],
public_key_pem=data["publicKeyPem"].encode(),
private_key_pem=data["privateKeyPem"].encode(),
)
# Fall back to old devices directory format
device_dir = Path.home() / ".openclaw" / "devices"
id_file = device_dir / "device_id"
pubkey_file = device_dir / "device_pubkey.pem"
privkey_file = device_dir / "device_privkey.pem"
if id_file.exists() and pubkey_file.exists() and privkey_file.exists():
device_id = id_file.read_text().strip()
public_key_pem = pubkey_file.read_bytes()
private_key_pem = privkey_file.read_bytes()
return cls(
device_id=device_id,
public_key_pem=public_key_pem,
private_key_pem=private_key_pem,
)
# Generate new identity (Ed25519, matching OpenClaw's approach)
from cryptography.hazmat.primitives.asymmetric import ed25519
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# Derive device ID from public key (SHA256 hash)
import hashlib
public_key_raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
device_id = hashlib.sha256(public_key_raw).hexdigest()
public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
# Save to disk
device_dir.mkdir(parents=True, exist_ok=True)
id_file.write_text(device_id)
pubkey_file.write_bytes(public_key_pem)
privkey_file.write_bytes(private_key_pem)
logger.info(f"Created new device identity: {device_id}")
return cls(
device_id=device_id,
public_key_pem=public_key_pem,
private_key_pem=private_key_pem,
)
def sign(self, payload: str) -> tuple[int, int]:
"""Sign a payload (for ECDSA keys)."""
private_key = serialization.load_pem_private_key(
self.private_key_pem, password=None, backend=default_backend()
)
signature = private_key.sign(payload.encode(), ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(signature)
return r, s
def sign_base64url(self, payload: str) -> str:
"""Sign payload and return base64url encoded signature (matches TypeScript crypto.sign)."""
import base64
private_key = serialization.load_pem_private_key(
self.private_key_pem, password=None, backend=default_backend()
)
# Ed25519 signing (used by OpenClaw)
sig = private_key.sign(payload.encode())
return base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
@dataclass
class GatewayHello:
"""Gateway hello response after connection."""
protocol: int
server_version: str
conn_id: str
methods: list[str]
events: list[str]
device_token: str | None = None
role: str | None = None
scopes: list[str] | None = None
@dataclass
class MessageEvent:
"""Incoming message event from agent."""
event: str
payload: dict[str, Any]
seq: int | None = None
@dataclass
class SendResult:
"""Result of sending a message."""
message_id: str
session_key: str
ok: bool
class OpenClawWebSocketClient:
"""WebSocket client for OpenClaw Gateway.
Supports:
- Device authentication
- Send messages to agents via sessions.send
- Receive real-time responses via event subscription
- Session management
Example usage:
async with OpenClawWebSocketClient() as client:
await client.connect()
result = await client.send_message(session_key, "Hello agent!")
async for event in client.subscribe(session_key):
print(event)
"""
def __init__(
self,
url: str = DEFAULT_GATEWAY_URL,
gateway_token: str | None = None,
device_identity: DeviceIdentity | None = None,
client_name: str = "cli", # Must be a valid GatewayClientId (cli, gateway-client, etc)
client_version: str = "1.0.0",
timeout_ms: int = 30000,
):
self.url = url
self.gateway_token = gateway_token or self._load_gateway_token()
self.device_identity = device_identity
self.client_name = client_name
self.client_version = client_version
self.timeout_ms = timeout_ms
self._ws: websockets.WebSocketClientProtocol | None = None
self._hello: GatewayHello | None = None
self._pending: dict[str, asyncio.Future] = {}
self._event_handlers: list[Callable[[MessageEvent], None]] = []
self._recv_task: asyncio.Task | None = None
self._nonce: str | None = None
self._connected = False
@staticmethod
def _load_gateway_token() -> str | None:
"""Load gateway token from ~/.openclaw/openclaw.json."""
try:
from pathlib import Path
token_file = Path.home() / ".openclaw" / "openclaw.json"
if token_file.exists():
data = json.loads(token_file.read_text())
return data.get("gateway", {}).get("auth", {}).get("token")
except Exception:
pass
return None
@property
def is_connected(self) -> bool:
return self._connected and self._ws is not None
async def __aenter__(self) -> "OpenClawWebSocketClient":
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self.disconnect()
async def connect(self) -> GatewayHello:
"""Connect to the Gateway and complete authentication handshake."""
if self._connected:
return self._hello
# Load or create device identity
if self.device_identity is None:
self.device_identity = DeviceIdentity.load_or_create()
logger.info(f"Connecting to OpenClaw Gateway at {self.url}")
self._ws = await websockets.connect(
self.url,
max_size=25 * 1024 * 1024, # 25MB max payload
)
# Start receive loop
self._recv_task = asyncio.create_task(self._recv_loop())
# Wait for connect.challenge
challenge = await self._wait_for_event("connect.challenge")
self._nonce = challenge.payload.get("nonce")
# Build connect params
connect_params = self._build_connect_params()
# Debug: log connect params
import logging
logging.basicConfig(level=logging.DEBUG)
logger.debug(f"Connect params: {connect_params}")
# Send connect request and wait for hello-ok
hello_event = await self._send_request("connect", connect_params, _allow_handshake=True)
self._hello = GatewayHello(
protocol=hello_event["protocol"],
server_version=hello_event["server"]["version"],
conn_id=hello_event["server"]["connId"],
methods=hello_event["features"]["methods"],
events=hello_event["features"]["events"],
device_token=hello_event.get("auth", {}).get("deviceToken"),
role=hello_event.get("auth", {}).get("role"),
scopes=hello_event.get("auth", {}).get("scopes"),
)
self._connected = True
logger.info(f"Connected to OpenClaw Gateway v{self._hello.server_version}")
logger.info(f"Supported methods: {self._hello.methods}")
return self._hello
def _build_connect_params(self) -> dict[str, Any]:
"""Build connect parameters with device authentication.
Implements V3 device auth payload format:
v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
"""
import base64
# Load public key - use Raw format for Ed25519 (32 bytes)
from cryptography.hazmat.primitives.asymmetric import ed25519
private_key = serialization.load_pem_private_key(
self.device_identity.private_key_pem, password=None, backend=default_backend()
)
if isinstance(private_key, ed25519.Ed25519PrivateKey):
public_key = private_key.public_key()
public_key_raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
else:
# ECDSA: use SPKI format
public_key = serialization.load_pem_public_key(
self.device_identity.public_key_pem, backend=default_backend()
)
public_key_raw = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).rstrip(b"=").decode()
# Build auth payload for signing (V3 format)
signed_at_ms = int(time.time() * 1000)
scopes = "operator.admin,operator.approvals,operator.pairing,operator.read,operator.write"
token = self.gateway_token or ""
platform = "darwin"
device_family = ""
# V3 payload: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
auth_payload = "|".join([
"v3",
self.device_identity.device_id,
self.client_name, # clientId
"backend", # clientMode
"operator", # role
scopes,
str(signed_at_ms),
token,
self._nonce or "",
platform,
device_family,
])
signature_b64 = self.device_identity.sign_base64url(auth_payload)
params = {
"minProtocol": PROTOCOL_VERSION,
"maxProtocol": PROTOCOL_VERSION,
"client": {
"id": self.client_name,
"version": self.client_version,
"platform": platform,
"mode": "backend",
},
"device": {
"id": self.device_identity.device_id,
"publicKey": public_key_b64,
"signature": signature_b64,
"signedAt": signed_at_ms,
"nonce": self._nonce,
},
"auth": {
"token": token or None,
},
"role": "operator",
"scopes": scopes.split(","),
}
# Debug output
print(f"DEBUG: nonce={self._nonce}", file=sys.stderr)
print(f"DEBUG: auth_payload={auth_payload}", file=sys.stderr)
print(f"DEBUG: connect params = {json.dumps(params, indent=2)}", file=sys.stderr)
return params
async def _recv_loop(self) -> None:
"""Receive and dispatch incoming messages."""
try:
async for raw in self._ws:
if raw is None:
break
await self._handle_frame(json.loads(raw))
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
logger.error(f"Receive loop error: {e}")
finally:
# Clean up pending futures
for future in self._pending.values():
if not future.done():
future.set_exception(Exception("Connection closed"))
self._pending.clear()
self._connected = False
async def _handle_frame(self, frame: dict[str, Any]) -> None:
"""Handle incoming frame."""
frame_type = frame.get("type")
if frame_type == "event":
event_name = frame.get("event", "")
payload = frame.get("payload", {})
seq = frame.get("seq")
event = MessageEvent(event=event_name, payload=payload, seq=seq)
# Handle connect challenge
if event_name == "connect.challenge":
nonce = payload.get("nonce")
if nonce:
self._nonce = nonce
challenge_event = MessageEvent(event=event_name, payload={"nonce": nonce}, seq=seq)
for handler in self._event_handlers:
try:
handler(challenge_event)
except Exception as e:
logger.error(f"Event handler error: {e}")
# Notify event handlers
for handler in self._event_handlers:
try:
handler(event)
except Exception as e:
logger.error(f"Event handler error: {e}")
elif frame_type == "res":
req_id = frame.get("id")
if req_id in self._pending:
future = self._pending.pop(req_id)
if frame.get("ok"):
future.set_result(frame.get("payload", {}))
else:
error = frame.get("error", {})
future.set_exception(Exception(f"{error.get('code', 'ERROR')}: {error.get('message', 'Unknown error')}"))
async def _wait_for_event(self, event_name: str, timeout_ms: int | None = None) -> MessageEvent:
"""Wait for a specific event."""
future: asyncio.Future = asyncio.Future()
timeout = timeout_ms or self.timeout_ms
def handler(event: MessageEvent) -> None:
if event.event == event_name:
if not future.done():
future.set_result(event)
self._event_handlers.append(handler)
try:
return await asyncio.wait_for(future, timeout / 1000)
finally:
self._event_handlers.remove(handler)
async def _send_request(self, method: str, params: dict[str, Any] | None = None, _allow_handshake: bool = False) -> dict[str, Any]:
"""Send a request and wait for response.
Args:
method: The RPC method name
params: Optional parameters for the method
_allow_handshake: If True, allow sending even during handshake (for connect method)
"""
if not self._ws:
raise Exception("Not connected to Gateway")
if not self._connected and not _allow_handshake:
raise Exception("Not connected to Gateway")
req_id = str(uuid.uuid4())
frame = {"type": "req", "id": req_id, "method": method}
if params:
frame["params"] = params
future: asyncio.Future = asyncio.Future()
self._pending[req_id] = future
await self._ws.send(json.dumps(frame))
try:
return await asyncio.wait_for(future, self.timeout_ms / 1000)
except asyncio.TimeoutError:
self._pending.pop(req_id, None)
raise TimeoutError(f"Request {method} timed out after {self.timeout_ms}ms")
async def call_method(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Call any RPC method on the Gateway.
Args:
method: The RPC method name (e.g., "sessions.list", "agents.list")
params: Optional parameters for the method
Returns:
The response payload from the Gateway
"""
return await self._send_request(method, params)
async def disconnect(self) -> None:
"""Disconnect from the Gateway."""
self._connected = False
if self._recv_task:
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
pass
if self._ws:
await self._ws.close()
self._ws = None
self._hello = None
# -------------------------------------------------------------------------
# Session operations
# -------------------------------------------------------------------------
async def list_sessions(
self,
limit: int = 50,
agent_id: str | None = None,
include_last_message: bool = True,
) -> list[dict[str, Any]]:
"""List active sessions."""
params: dict[str, Any] = {"limit": limit, "includeLastMessage": include_last_message}
if agent_id:
params["agentId"] = agent_id
result = await self._send_request("sessions.list", params)
return result.get("sessions", [])
async def resolve_session(
self,
agent_id: str | None = None,
label: str | None = None,
channel: str | None = None,
include_global: bool = True,
) -> str | None:
"""Resolve a session key by agent and optional channel."""
params: dict[str, Any] = {"includeGlobal": include_global}
if agent_id:
params["agentId"] = agent_id
if label:
params["label"] = label
result = await self._send_request("sessions.resolve", params)
sessions = result.get("sessions", [])
if sessions:
return sessions[0].get("key")
return None
async def send_message(
self,
session_key: str,
message: str,
thinking: str | None = None,
timeout_ms: int | None = None,
) -> dict[str, Any]:
"""Send a message to an agent session.
Args:
session_key: The session key (format: agentId:channelId:accountId:conversationId)
message: The message text to send
thinking: Optional thinking/reasoning to include
timeout_ms: Timeout for the request
Returns:
The response payload containing message ID and result
"""
params: dict[str, Any] = {
"key": session_key,
"message": message,
}
if thinking:
params["thinking"] = thinking
# Use shorter timeout for send since it waits for agent response
result = await self._send_request(
"sessions.send",
params,
)
return result
async def subscribe(self, session_key: str) -> AsyncMessageIterator:
"""Subscribe to messages from a session.
Usage:
async for event in client.subscribe(session_key):
print(f"Event: {event.event}", event.payload)
Args:
session_key: The session key to subscribe to
Returns:
AsyncIterator of MessageEvents
"""
# First subscribe to the session
await self._send_request("sessions.messages.subscribe", {"key": session_key})
return AsyncMessageIterator(self, session_key)
# -------------------------------------------------------------------------
# Agent operations
# -------------------------------------------------------------------------
async def list_agents(self) -> list[dict[str, Any]]:
"""List configured agents."""
result = await self._send_request("agents.list", {})
return result.get("agents", [])
async def get_agent(self, agent_id: str) -> dict[str, Any] | None:
"""Get agent details."""
agents = await self.list_agents()
for agent in agents:
if agent.get("id") == agent_id:
return agent
return None
# -------------------------------------------------------------------------
# Channel operations
# -------------------------------------------------------------------------
async def channels_status(self, probe: bool = False) -> dict[str, Any]:
"""Get channel status."""
params = {"probe": probe} if probe else {}
return await self._send_request("channels.status", params)
async def channels_list(self) -> list[dict[str, Any]]:
"""List configured channels."""
result = await self._send_request("channels.list", {})
return result.get("channels", [])
# -------------------------------------------------------------------------
# Convenience methods
# -------------------------------------------------------------------------
async def send_to_agent(
self,
agent_id: str,
message: str,
channel: str | None = None,
label: str | None = None,
) -> dict[str, Any]:
"""Convenience method to send a message to an agent.
Resolves the session automatically.
Args:
agent_id: The agent ID
message: Message to send
channel: Optional channel to route through
label: Optional session label
Returns:
The agent's response
"""
session_key = await self.resolve_session(agent_id=agent_id, label=label, channel=channel)
if not session_key:
raise ValueError(f"No session found for agent {agent_id}")
return await self.send_message(session_key, message)
def add_event_handler(self, handler: Callable[[MessageEvent], None]) -> None:
"""Add an event handler for incoming events."""
self._event_handlers.append(handler)
def remove_event_handler(self, handler: Callable[[MessageEvent], None]) -> None:
"""Remove an event handler."""
self._event_handlers.remove(handler)
class AsyncMessageIterator:
"""Async iterator for session messages."""
def __init__(self, client: OpenClawWebSocketClient, session_key: str):
self._client = client
self._session_key = session_key
self._queue: asyncio.Queue[MessageEvent] = asyncio.Queue()
self._handler_added = False
def _on_event(self, event: MessageEvent) -> None:
"""Handle incoming event and check if it's for our session."""
# Filter to session-specific events
payload = event.payload or {}
event_session_key = payload.get("sessionKey") or payload.get("key")
if event_session_key == self._session_key or event.event.startswith("sessions."):
self._queue.put_nowait(event)
async def __aiter__(self) -> "AsyncMessageIterator":
if not self._handler_added:
self._client.add_event_handler(self._on_event)
self._handler_added = True
return self
async def __anext__(self) -> MessageEvent:
return await self._queue.get()
# -----------------------------------------------------------------------------
# Synchronous convenience functions
# -----------------------------------------------------------------------------
async def send_to_agent(
message: str,
agent_id: str,
gateway_url: str = DEFAULT_GATEWAY_URL,
gateway_token: str | None = None,
channel: str | None = None,
label: str | None = None,
timeout_ms: int = 60000,
) -> dict[str, Any]:
"""Send a message to an agent and wait for response.
This is a convenience function for one-shot message sending.
Args:
message: The message to send
agent_id: The agent ID to target
gateway_url: Gateway WebSocket URL
gateway_token: Optional gateway auth token
channel: Optional channel to route through
label: Optional session label
timeout_ms: Timeout in milliseconds
Returns:
The agent's response
Example:
response = await send_to_agent("Hello!", agent_id="my-agent")
"""
async with OpenClawWebSocketClient(
url=gateway_url,
gateway_token=gateway_token,
) as client:
await client.connect()
return await client.send_to_agent(
agent_id=agent_id,
message=message,
channel=channel,
label=label,
)
async def list_active_sessions(
gateway_url: str = DEFAULT_GATEWAY_URL,
gateway_token: str | None = None,
agent_id: str | None = None,
) -> list[dict[str, Any]]:
"""List active sessions.
Args:
gateway_url: Gateway WebSocket URL
gateway_token: Optional gateway auth token
agent_id: Optional agent ID to filter by
Returns:
List of active sessions
"""
async with OpenClawWebSocketClient(
url=gateway_url,
gateway_token=gateway_token,
) as client:
await client.connect()
return await client.list_sessions(agent_id=agent_id)

36
shared/models/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Shared data models."""
from shared.models.openclaw import (
OpenClawStatus,
SessionEntry,
SessionHistory,
SessionHistoryEvent,
SessionsList,
CronJob,
CronList,
ApprovalRequest,
ApprovalsList,
normalize_status,
normalize_sessions,
normalize_session_history,
normalize_cron_jobs,
normalize_approvals,
)
__all__ = [
"OpenClawStatus",
"SessionEntry",
"SessionHistory",
"SessionHistoryEvent",
"SessionsList",
"CronJob",
"CronList",
"ApprovalRequest",
"ApprovalsList",
"normalize_status",
"normalize_sessions",
"normalize_session_history",
"normalize_cron_jobs",
"normalize_approvals",
]

1103
shared/models/openclaw.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,16 @@ cleanup() {
fi fi
} }
kill_port() {
local port="$1"
local pids=$(lsof -ti :${port} 2>/dev/null || true)
if [ -n "$pids" ]; then
echo -e "${YELLOW}Port ${port} is in use, killing PID(s): ${pids}${NC}"
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 0.5
fi
}
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
if [ $# -gt 0 ]; then if [ $# -gt 0 ]; then
@@ -67,11 +77,13 @@ fi
export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}" export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}"
export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}" export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}"
export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}" export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}"
export OPENCLAW_SERVICE_URL="${OPENCLAW_SERVICE_URL:-http://localhost:18789}"
echo "" echo ""
echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}" echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}"
echo " agent_service: http://localhost:8000" echo " agent_service: http://localhost:8000"
echo " runtime_service: http://localhost:8003" echo " runtime_service: http://localhost:8003"
echo " openclaw_gateway: ws://localhost:18789"
echo " trading_service: http://localhost:8001" echo " trading_service: http://localhost:8001"
echo " news_service: http://localhost:8002" echo " news_service: http://localhost:8002"
echo "" echo ""
@@ -79,13 +91,28 @@ echo "Exported backend preference URLs:"
echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}" echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}"
echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}" echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}"
echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}" echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}"
echo " OPENCLAW_SERVICE_URL=${OPENCLAW_SERVICE_URL}"
echo "" echo ""
echo -e "${GREEN}Checking ports...${NC}"
kill_port 8000
kill_port 8001
kill_port 8002
kill_port 8003
kill_port 8765
start_service "agent_service" "backend.apps.agent_service:app" 8000 start_service "agent_service" "backend.apps.agent_service:app" 8000
start_service "runtime_service" "backend.apps.runtime_service:app" 8003 start_service "runtime_service" "backend.apps.runtime_service:app" 8003
start_service "trading_service" "backend.apps.trading_service:app" 8001 start_service "trading_service" "backend.apps.trading_service:app" 8001
start_service "news_service" "backend.apps.news_service:app" 8002 start_service "news_service" "backend.apps.news_service:app" 8002
echo -e "${GREEN}Starting Gateway (WebSocket, port 8765)...${NC}"
SERVICE_NAME="gateway" python -m backend.main \
--mode live \
--host 0.0.0.0 \
--port 8765 &
PIDS+=($!)
echo -e "${GREEN}Split services are running.${NC}" echo -e "${GREEN}Split services are running.${NC}"
echo "Use Ctrl+C to stop all services." echo "Use Ctrl+C to stop all services."
wait wait