initial commit

This commit is contained in:
cubecraft-agents[bot]
2026-04-25 19:02:57 +00:00
commit f490098af6
60 changed files with 11934 additions and 0 deletions

32
api-client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@control-center/openclaw-api-client",
"version": "0.1.0",
"description": "OpenClaw Gateway API client and WebSocket integration for the Control Center Command Hub",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "jest",
"lint": "eslint src/"
},
"dependencies": {
"ws": "^8.16.0",
"rxjs": "^7.8.1",
"eventsource": "^2.0.2"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/ws": "^8.5.10",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
"@types/jest": "^29.5.0",
"eslint": "^8.56.0"
},
"peerDependencies": {
"rxjs": "^7.8.1"
},
"license": "UNLICENSED",
"private": true
}

View File

@@ -0,0 +1,5 @@
/**
* @fileoverview Re-export barrel for all model types.
*/
export * from './types';

View File

@@ -0,0 +1,426 @@
/**
* @fileoverview Core data models for the OpenClaw Control Center API client.
*
* These models bridge OpenClaw Gateway primitives (session keys, status events,
* tool responses) to the AgentCardData interface consumed by the Angular frontend.
*
* Design principle: every model maps 1:1 to a Gateway concept. Transformation
* to UI-specific shapes (like AgentCardData) happens in the service layer.
*/
// ─── Agent Status ──────────────────────────────────────────────────────────
/**
* Agent operational status, derived from session activity and Gateway events.
*
* Mapping from OpenClaw session `status` field:
* - "running" → ACTIVE — agent is currently processing a turn
* - "done" → IDLE — agent completed its last turn, no active work
* - "streaming"→ THINKING — LLM call in flight, tokens streaming
* - "error" → ERROR — agent encountered an unhandled error
* - (no session) → IDLE — no active session exists for this agent
*/
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error';
/**
* Extended status including offline — not all agents have active sessions.
* Used internally; the UI only sees AgentStatus (offline maps to idle).
*/
export type AgentLifecycleStatus = AgentStatus | 'offline';
// ─── Agent Card Data ───────────────────────────────────────────────────────
/**
* The primary data shape consumed by Agent Card components in the Command Hub.
* Matches the TypeScript interface from the design spec exactly.
*/
export interface AgentCardData {
/** Agent identifier, e.g. "otto", "dex", "hex" */
id: string;
/** Human-readable name, e.g. "Otto", "Dex" */
displayName: string;
/** Role description from agent identity, e.g. "Orchestrator Agent" */
role: string;
/** Current operational status */
status: AgentStatus;
/** Description of the current task, if any */
currentTask?: string;
/** Task progress percentage (0100), if trackable */
taskProgress?: number;
/** Elapsed time string, e.g. "04m 12s" */
taskElapsed?: string;
/** Full session key, e.g. "agent:otto:telegram:direct:8787451565" */
sessionKey: string;
/** Channel the agent is operating on, e.g. "telegram", "discord" */
channel: string;
/** ISO timestamp of last activity */
lastActivity: string;
/** Error message when status is 'error' */
errorMessage?: string;
}
// ─── OpenClaw Gateway Primitives ───────────────────────────────────────────
/**
* Raw agent configuration from OpenClaw's config (openclaw.json agents.list[]).
* Not all fields are always present — this is a partial reflection.
*/
export interface OpenClawAgentConfig {
id: string;
name?: string;
emoji?: string;
avatar?: string;
model?: string | { primary: string };
workspace?: string;
agentDir?: string;
identity?: {
name?: string;
emoji?: string;
avatar?: string;
theme?: string;
};
routing?: Array<{
channel: string;
account?: string;
peer?: string;
group?: string;
}>;
subagents?: {
allowAgents?: string[];
maxChildrenPerAgent?: number;
};
}
/**
* Session entry returned by `sessions.list` RPC or `/tools/invoke sessions_list`.
* This is the raw shape from the Gateway.
*/
export interface OpenClawSession {
key: string;
kind: string;
channel: string;
origin?: {
provider: string;
accountId?: string;
peerId?: string;
};
displayName?: string;
deliveryContext?: {
to?: string;
channel?: string;
accountId?: string;
peerId?: string;
};
updatedAt: number; // epoch ms
sessionId: string; // UUID
model?: string;
modelProvider?: string;
contextTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
estimatedCostUsd?: number;
status?: 'done' | 'running' | 'streaming' | 'error' | 'aborted';
startedAt?: number; // epoch ms
endedAt?: number; // epoch ms
runtimeMs?: number;
systemSent?: boolean;
abortedLastRun?: boolean;
lastTo?: string;
label?: string;
transcriptPath?: string;
agentId?: string;
inputTokens?: number;
outputTokens?: number;
providerOverride?: string;
modelOverride?: string;
}
/**
* Gateway health response from `/health` endpoint.
*/
export interface GatewayHealth {
ok: boolean;
status: 'live' | 'stale' | 'down';
}
/**
* Identity payload for an agent, returned by `agent.identity.get` RPC.
*/
export interface AgentIdentity {
agentId: string;
name: string;
emoji?: string;
avatar?: string;
theme?: string;
description?: string;
}
// ─── WebSocket Protocol Frames ─────────────────────────────────────────────
/**
* Base frame shape for the OpenClaw Gateway WebSocket protocol v3.
* - Request: { type: "req", id, method, params }
* - Response: { type: "res", id, ok, payload|error }
* - Event: { type: "event", event, payload, seq?, stateVersion? }
*/
export type WsFrame =
| WsRequest
| WsResponse
| WsEvent;
export interface WsRequest {
type: 'req';
id: string;
method: string;
params: Record<string, unknown>;
}
export interface WsResponse {
type: 'res';
id: string;
ok: boolean;
payload?: unknown;
error?: {
type: string;
message: string;
details?: Record<string, unknown>;
};
}
export interface WsEvent {
type: 'event';
event: string;
payload: unknown;
seq?: number;
stateVersion?: number;
}
// ─── Event Payloads ────────────────────────────────────────────────────────
/**
* Event names emitted by the Gateway that the Command Hub subscribes to.
*/
export type GatewayEventName =
| 'sessions.changed'
| 'session.message'
| 'session.tool'
| 'presence'
| 'health'
| 'tick'
| 'heartbeat'
| 'cron'
| 'shutdown'
| 'exec.approval.requested'
| 'exec.approval.resolved'
| 'node.pair.requested'
| 'node.pair.resolved'
| 'chat'
| 'device.pair.requested'
| 'device.pair.resolved';
/**
* sessions.changed event payload — broadcast when the session index updates.
*/
export interface SessionsChangedPayload {
added?: string[]; // session keys added
removed?: string[]; // session keys removed
updated?: string[]; // session keys with metadata changes
snapshot?: OpenClawSession[];
}
/**
* session.message event payload — transcript message for a subscribed session.
*/
export interface SessionMessagePayload {
sessionKey: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
timestamp: number;
toolCalls?: ToolCallInfo[];
}
/**
* session.tool event payload — tool execution event for a subscribed session.
*/
export interface SessionToolPayload {
sessionKey: string;
toolName: string;
status: 'started' | 'completed' | 'error';
duration?: number;
result?: string;
error?: string;
}
/**
* Presence event payload — devices connected to the gateway.
*/
export interface PresencePayload {
devices: Array<{
deviceId: string;
roles: string[];
scopes: string[];
connected: boolean;
lastSeen?: number;
}>;
}
/**
* Health event payload — gateway health snapshot update.
*/
export interface HealthEventPayload {
ok: boolean;
status: string;
uptime?: number;
agentsOnline?: number;
}
/**
* Simplified tool call info extracted from transcript events.
*/
export interface ToolCallInfo {
name: string;
status: 'started' | 'completed' | 'error';
args?: Record<string, unknown>;
result?: string;
error?: string;
}
// ─── API Request/Response Types ────────────────────────────────────────────
/**
* Tools invoke request body for the HTTP `/tools/invoke` endpoint.
*/
export interface ToolsInvokeRequest {
tool: string;
action?: string;
args?: Record<string, unknown>;
sessionKey?: string;
dryRun?: boolean;
}
/**
* Tools invoke success response.
*/
export interface ToolsInvokeResponse<T = unknown> {
ok: true;
result: {
content?: Array<{ type: string; text: string }>;
details?: T;
};
}
/**
* Tools invoke error response.
*/
export interface ToolsInvokeErrorResponse {
ok: false;
error: {
type: string;
message: string;
};
}
// ─── Connect Handshake ────────────────────────────────────────────────────
/**
* Connect request params for the WebSocket handshake.
*/
export interface ConnectParams {
minProtocol: number;
maxProtocol: number;
client: {
id: string;
version: string;
platform: string;
mode: 'operator' | 'node';
};
role: 'operator' | 'node';
scopes: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
auth: {
token?: string;
password?: string;
deviceToken?: string;
};
locale?: string;
userAgent?: string;
device?: {
id: string;
publicKey?: string;
signature?: string;
signedAt?: number;
nonce?: string;
};
}
/**
* Hello-ok response payload after successful connect.
*/
export interface HelloOkPayload {
type: 'hello-ok';
protocol: number;
policy: {
tickIntervalMs: number;
};
auth?: {
deviceToken?: string;
role: string;
scopes: string[];
deviceTokens?: Array<{
deviceToken: string;
role: string;
scopes: string[];
}>;
};
}
/**
* Connect challenge event from the gateway.
*/
export interface ConnectChallengePayload {
nonce: string;
ts: number;
}
// ─── Agent Role Map ────────────────────────────────────────────────────────
/**
* Known agent roles. This maps agent IDs to their functional descriptions
* for display in the Command Hub. Can be overridden by agent identity config.
*/
export const AGENT_ROLES: Record<string, string> = {
main: 'Primary Assistant',
otto: 'Orchestrator Agent',
dave: 'Network Admin Agent',
bob: 'Content Writer Agent',
stuart: 'Image & Creative Agent',
phil: 'Home Automation Agent',
carl: 'Security Agent',
larry: 'Business Agent',
mel: 'E-Commerce Agent',
norbert: 'Product Agent',
jerry: 'Market Research Agent',
rex: 'Frontend Dev Agent',
dex: 'Backend Dev Agent',
hex: 'Database Agent',
pip: 'Raspberry Pi Agent',
nano: 'ESP32/Firmware Agent',
axiom: 'Utility Agent',
bonnie: 'Music Agent',
sketch: 'UI/UX Design Agent',
flip: 'Mobile Dev Agent',
buzz: 'SEO Agent',
aries: 'Companion Agent',
};

View File

@@ -0,0 +1,272 @@
/**
* @fileoverview OpenClaw Gateway HTTP API client.
*
* Wraps the Gateway's REST surface:
* - `GET /health` — gateway health check
* - `POST /tools/invoke` — invoke a tool directly via HTTP
* - `GET /v1/models` — OpenAI-compatible model list (if enabled)
*
* The primary data-fetching path is `/tools/invoke`, which provides access to:
* - `sessions_list` — list active sessions across agents
* - `agents_list` — list configured agents
* - `linear_*` — Linear issue management (if plugin enabled)
*
* Note: Many WS RPC methods are NOT available over HTTP due to the built-in
* deny list (exec, spawn, shell, fs_write, sessions_spawn, etc.).
* For full Gateway access, use the WebSocket client.
*/
import { OpenClawClientConfig } from '../utils/config';
import type {
OpenClawSession,
OpenClawAgentConfig,
GatewayHealth,
ToolsInvokeRequest,
ToolsInvokeResponse,
ToolsInvokeErrorResponse,
} from '../models/types';
/**
* Parsed session list from the tools invoke response.
*/
export interface SessionListResult {
count: number;
sessions: OpenClawSession[];
}
/**
* Parsed agent list from the tools invoke response.
*/
export interface AgentListResult {
requester: string;
allowAny: boolean;
agents: Array<{ id: string; configured: boolean }>;
}
/**
* Event types for the HTTP client.
*/
export type HttpClientEvent =
| { type: 'request-sent'; method: string; url: string }
| { type: 'response-received'; method: string; url: string; status: number }
| { type: 'error'; method: string; url: string; error: Error };
export class OpenClawHttpClient {
private readonly baseUrl: string;
private readonly authToken: string;
private readonly authMode: string;
private readonly timeoutMs: number;
constructor(private readonly config: OpenClawClientConfig) {
this.baseUrl = config.gatewayUrl.replace(/\/+$/, '');
this.authToken = config.authToken;
this.authMode = config.authMode;
this.timeoutMs = config.httpTimeoutMs;
}
// ─── Health ───────────────────────────────────────────────────────────
/**
* Check gateway health. This is the simplest endpoint — no auth required.
* `GET /health`
*/
async getHealth(): Promise<GatewayHealth> {
return this.get<GatewayHealth>('/health');
}
// ─── Tools Invoke ────────────────────────────────────────────────────
/**
* Invoke a tool via the HTTP endpoint.
* `POST /tools/invoke`
*
* This is the primary way to fetch data without a WebSocket connection.
* Available tools are filtered by the Gateway's HTTP deny list.
*/
async invokeTool<T = unknown>(
tool: string,
args: Record<string, unknown> = {},
sessionKey?: string
): Promise<T> {
const body: ToolsInvokeRequest = {
tool,
args,
...(sessionKey ? { sessionKey } : {}),
};
const response = await this.post<ToolsInvokeResponse<T> | ToolsInvokeErrorResponse>(
'/tools/invoke',
body
);
if (!response.ok) {
const err = response as ToolsInvokeErrorResponse;
throw new Error(`Tool invoke failed: ${err.error.type}${err.error.message}`);
}
const success = response as ToolsInvokeResponse<T>;
return (success.result.details ?? success.result.content) as T;
}
// ─── Session Queries ─────────────────────────────────────────────────
/**
* List sessions across all agents via `/tools/invoke`.
* Maps to the `sessions_list` tool.
*
* @param activeMinutes Only return sessions updated within the last N minutes
* @param allAgents Aggregate across all configured agents
*/
async listSessions(
activeMinutes?: number,
allAgents: boolean = true
): Promise<SessionListResult> {
const args: Record<string, unknown> = { allAgents };
if (activeMinutes !== undefined) {
args.activeMinutes = activeMinutes;
}
const raw = await this.invokeTool<SessionListResult>('sessions_list', args);
// The tools invoke response wraps session data in `details` or `content`
// Normalize it here
if (raw && typeof raw === 'object' && 'sessions' in raw) {
return raw as SessionListResult;
}
// Fallback: try to parse from content text
const contentResult = await this.invokeTool<{ content?: Array<{ type: string; text: string }> }>(
'sessions_list',
args
);
if (contentResult?.content?.[0]?.text) {
return JSON.parse(contentResult.content[0].text) as SessionListResult;
}
return { count: 0, sessions: [] };
}
/**
* Get sessions for a specific agent.
*/
async listAgentSessions(
agentId: string,
activeMinutes?: number
): Promise<SessionListResult> {
const args: Record<string, unknown> = { allAgents: false, agentId };
if (activeMinutes !== undefined) {
args.activeMinutes = activeMinutes;
}
const raw = await this.invokeTool<SessionListResult>('sessions_list', args);
if (raw && typeof raw === 'object' && 'sessions' in raw) {
return raw as SessionListResult;
}
return { count: 0, sessions: [] };
}
// ─── Agent Queries ───────────────────────────────────────────────────
/**
* List configured agents via `/tools/invoke`.
* Maps to the `agents_list` tool.
*
* Note: This only returns agents the current session's policy allows.
* For the full agent list, prefer reading from config or using the WS protocol.
*/
async listAgents(): Promise<AgentListResult> {
return this.invokeTool<AgentListResult>('agents_list', {});
}
// ─── Model Queries ──────────────────────────────────────────────────
/**
* List available models via the OpenAI-compatible endpoint.
* Requires `gateway.http.endpoints.chatCompletions.enabled: true`.
* `GET /v1/models`
*/
async listModels(): Promise<unknown> {
return this.get('/v1/models');
}
// ─── HTTP Primitives ─────────────────────────────────────────────────
private async get<T>(path: string): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Accept': 'application/json',
};
if (this.authMode === 'token' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
} else if (this.authMode === 'password' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timeout (${this.timeoutMs}ms): GET ${path}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
private async post<T>(path: string, body: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (this.authMode === 'token' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
} else if (this.authMode === 'password' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${response.statusText}${text}`);
}
return response.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timeout (${this.timeoutMs}ms): POST ${path}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview Configuration for the OpenClaw API client.
*
* Centralizes gateway URL, auth token, WebSocket options, and polling intervals.
* Designed for Angular environment injection — values can be overridden at build time
* or via Angular's environment.ts files.
*/
export interface OpenClawClientConfig {
/** Gateway HTTP base URL, e.g. "http://10.60.1.145:18789" */
gatewayUrl: string;
/** Gateway WebSocket URL, e.g. "ws://10.60.1.145:18789" */
gatewayWsUrl: string;
/** Auth token (from OPENCLAW_GATEWAY_TOKEN or configured secret) */
authToken: string;
/** Auth mode — must match gateway.auth.mode in openclaw.json */
authMode: 'token' | 'password' | 'none' | 'trusted-proxy';
/** How often (ms) to poll for session status when WS is not connected */
pollIntervalMs: number;
/** How often (ms) to poll for agent list refresh */
agentListPollMs: number;
/** WebSocket reconnect delay (ms), with exponential backoff */
wsReconnectDelayMs: number;
/** Maximum WebSocket reconnect attempts before giving up */
wsMaxReconnectAttempts: number;
/** Request timeout for HTTP calls (ms) */
httpTimeoutMs: number;
/** Protocol version for WS handshake */
protocolVersion: number;
/** Client identity for WS handshake */
clientId: string;
/** Client version for WS handshake */
clientVersion: string;
/** Client platform for WS handshake */
clientPlatform: string;
}
/**
* Default configuration — targets the LAN-bound gateway on port 18789.
* Override specific fields via Angular environment injection.
*/
export const DEFAULT_CONFIG: OpenClawClientConfig = {
gatewayUrl: 'http://10.60.1.145:18789',
gatewayWsUrl: 'ws://10.60.1.145:18789',
authToken: '',
authMode: 'token',
pollIntervalMs: 5_000,
agentListPollMs: 30_000,
wsReconnectDelayMs: 1_000,
wsMaxReconnectAttempts: 50,
httpTimeoutMs: 15_000,
protocolVersion: 3,
clientId: 'control-center',
clientVersion: '0.1.0',
clientPlatform: 'web',
};
/**
* Create a resolved config by merging partial overrides onto defaults.
*/
export function createConfig(overrides: Partial<OpenClawClientConfig> = {}): OpenClawClientConfig {
return { ...DEFAULT_CONFIG, ...overrides };
}

View File

@@ -0,0 +1,6 @@
/**
* @fileoverview Re-export barrel for utilities.
*/
export * from './config';
export * from './status-mapper';

View File

@@ -0,0 +1,178 @@
/**
* @fileoverview Status mapping utilities.
*
* Translates OpenClaw Gateway session statuses and timestamps
* into the AgentStatus enum and elapsed-time strings used by AgentCardData.
*/
import { AgentStatus, AgentLifecycleStatus, OpenClawSession, AGENT_ROLES } from '../models/types';
/**
* Map an OpenClaw session status to an AgentStatus for the UI.
*
* Mapping logic:
* - "running" or "streaming" with no tokens yet → ACTIVE (agent processing turn)
* - "streaming" with output tokens → THINKING (LLM call in flight)
* - "done" → IDLE (agent finished, waiting for next input)
* - "error" → ERROR
* - "aborted" → IDLE (abort = intentional stop, not an error)
* - null/undefined → IDLE (no active session = idle)
*/
export function mapSessionStatusToAgentStatus(sessionStatus?: string | null): AgentStatus {
switch (sessionStatus) {
case 'running':
return 'active';
case 'streaming':
return 'thinking';
case 'done':
return 'idle';
case 'error':
return 'error';
case 'aborted':
return 'idle';
default:
return 'idle';
}
}
/**
* Determine lifecycle status for an agent that may not have an active session.
* If no session exists, the agent is considered "offline".
*/
export function determineLifecycleStatus(
session?: OpenClawSession | null
): AgentLifecycleStatus {
if (!session) return 'offline';
return mapSessionStatusToAgentStatus(session.status);
}
/**
* Convert an OpenClaw session to the lifecycle status used by the UI.
* Offline agents are presented as "idle" to the UI.
*/
export function lifecycleToUiStatus(lifecycle: AgentLifecycleStatus): AgentStatus {
return lifecycle === 'offline' ? 'idle' : lifecycle;
}
/**
* Format elapsed time from a session's startedAt timestamp.
* Returns a human-readable string like "04m 12s" or "1h 23m".
*
* If the session hasn't started or is done, returns undefined.
*/
export function formatElapsedTime(
session?: OpenClawSession | null,
nowMs?: number
): string | undefined {
if (!session?.startedAt) return undefined;
const endTime = session.endedAt || nowMs || Date.now();
const elapsedMs = endTime - session.startedAt;
if (elapsedMs < 0) return undefined;
const totalSeconds = Math.floor(elapsedMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
return `${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
}
/**
* Extract the channel name from a session key.
* Session key format: "agent:<agentId>:<channel>:<kind>:<peerId>"
* Or from the session's channel/origin fields.
*/
export function extractChannelFromSession(session: OpenClawSession): string {
// Prefer explicit channel field
if (session.channel && session.channel !== 'heartbeat') {
return session.channel;
}
// Fall back to origin provider
if (session.origin?.provider) {
return session.origin.provider;
}
// Parse session key: "agent:otto:telegram:direct:8787451565"
const parts = session.key.split(':');
if (parts.length >= 3 && parts[0] === 'agent') {
return parts[2];
}
return 'unknown';
}
/**
* Extract the agent ID from a session key.
* Session key format: "agent:<agentId>:..."
*/
export function extractAgentIdFromSessionKey(sessionKey: string): string {
const parts = sessionKey.split(':');
if (parts[0] === 'agent' && parts.length >= 2) {
return parts[1];
}
return sessionKey;
}
/**
* Get the role description for an agent ID.
* Falls back to AGENT_ROLES map, then to a generic string.
*/
export function getAgentRole(agentId: string): string {
return AGENT_ROLES[agentId] || `${agentId} Agent`;
}
/**
* Estimate task progress based on session state.
*
* OpenClaw doesn't natively expose a 0100 progress percentage.
* We derive an approximation:
* - "running" session → 1050% (early in the task, we don't know)
* - "streaming" → 6090% (LLM is responding)
* - "done" → 100%
* - "error" → whatever progress was made before the error
*
* This is intentionally coarse. A future enhancement could parse
* sub-agent progress or tool call completion ratios.
*/
export function estimateTaskProgress(session?: OpenClawSession | null): number | undefined {
if (!session) return undefined;
switch (session.status) {
case 'running':
return 25; // processing, early stage
case 'streaming':
return 75; // LLM responding
case 'done':
return 100;
case 'error':
return undefined; // unknown how far it got
case 'aborted':
return undefined;
default:
return undefined;
}
}
/**
* Derive a current-task description from session metadata.
* Uses the session label (for cron sessions) or constructs a generic description.
*/
export function deriveCurrentTask(session?: OpenClawSession | null): string | undefined {
if (!session) return undefined;
// Cron sessions have a label
if (session.label) return session.label;
// Sessions with displayName that isn't generic
if (session.displayName && session.displayName !== 'direct' && session.displayName !== 'heartbeat') {
return session.displayName;
}
// Active sessions get a generic description
if (session.status === 'running' || session.status === 'streaming') {
return 'Processing task...';
}
return undefined;
}

View File

@@ -0,0 +1,5 @@
/**
* @fileoverview Re-export barrel for WebSocket module.
*/
export * from './ws-client';

View File

@@ -0,0 +1,584 @@
/**
* @fileoverview OpenClaw Gateway WebSocket client.
*
* Implements the Gateway WS protocol v3:
* 1. Connect and receive `connect.challenge`
* 2. Send `connect` request with auth
* 3. Receive `hello-ok` (or error)
* 4. Subscribe to session events (`sessions.subscribe`, `sessions.messages.subscribe`)
* 5. Listen for real-time events (session changes, messages, tool calls, presence)
* 6. Send RPC requests (agents.list, sessions.list, sessions.abort, etc.)
*
* Uses RxJS subjects for reactive event streaming — the Angular frontend
* can subscribe to specific event types without callback hell.
*/
import { Subject, Observable, BehaviorSubject, Subscription } from 'rxjs';
import { filter, map, share } from 'rxjs/operators';
import {
WsFrame,
WsRequest,
WsResponse,
WsEvent,
ConnectParams,
HelloOkPayload,
ConnectChallengePayload,
GatewayEventName,
SessionsChangedPayload,
SessionMessagePayload,
SessionToolPayload,
PresencePayload,
HealthEventPayload,
OpenClawSession,
} from '../models/types';
import { OpenClawClientConfig } from '../utils/config';
// ─── Connection State ──────────────────────────────────────────────────────
export type WsConnectionState =
| 'disconnected'
| 'connecting'
| 'challenged'
| 'authenticating'
| 'connected'
| 'reconnecting'
| 'error';
export interface WsConnectionInfo {
state: WsConnectionState;
protocol?: number;
sessionId?: string;
reconnectAttempt?: number;
lastError?: string;
}
// ─── RPC Pending Request ──────────────────────────────────────────────────
interface PendingRpc {
resolve: (payload: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
}
// ─── Client ───────────────────────────────────────────────────────────────
export class OpenClawWebSocketClient {
private ws: WebSocket | null = null;
private rpcId = 0;
private pendingRpc = new Map<string, PendingRpc>();
private challengeNonce = '';
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private subscriptions = new Set<string>(); // session keys we've subscribed to
private sessionSubscriptionsActive = false;
// ─── RxJS Streams ────────────────────────────────────────────────────
/** All raw WebSocket events */
private readonly rawEvent$ = new Subject<WsEvent>();
/** Connection state changes */
readonly connectionState$ = new BehaviorSubject<WsConnectionInfo>({
state: 'disconnected',
});
/** Session index changed events */
readonly sessionsChanged$ = this.rawEvent$.pipe(
filter((e) => e.event === 'sessions.changed'),
map((e) => e.payload as SessionsChangedPayload),
share()
);
/** Session message events (for subscribed sessions) */
readonly sessionMessage$ = this.rawEvent$.pipe(
filter((e) => e.event === 'session.message'),
map((e) => e.payload as SessionMessagePayload),
share()
);
/** Session tool events (for subscribed sessions) */
readonly sessionTool$ = this.rawEvent$.pipe(
filter((e) => e.event === 'session.tool'),
map((e) => e.payload as SessionToolPayload),
share()
);
/** Presence events */
readonly presence$ = this.rawEvent$.pipe(
filter((e) => e.event === 'presence'),
map((e) => e.payload as PresencePayload),
share()
);
/** Health events */
readonly health$ = this.rawEvent$.pipe(
filter((e) => e.event === 'health'),
map((e) => e.payload as HealthEventPayload),
share()
);
/** Tick/keepalive events */
readonly tick$ = this.rawEvent$.pipe(
filter((e) => e.event === 'tick'),
share()
);
/** Gateway shutdown notification */
readonly shutdown$ = this.rawEvent$.pipe(
filter((e) => e.event === 'shutdown'),
share()
);
/** Any event with a specific name */
on(event: GatewayEventName): Observable<unknown> {
return this.rawEvent$.pipe(
filter((e) => e.event === event),
map((e) => e.payload)
);
}
// ─── Constructor ─────────────────────────────────────────────────────
constructor(private readonly config: OpenClawClientConfig) {}
// ─── Connection Lifecycle ───────────────────────────────────────────
/**
* Connect to the Gateway WebSocket.
* Handles the full handshake: challenge → connect → hello-ok.
*/
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
return; // already connected
}
this.updateState({ state: 'connecting' });
const url = this.config.gatewayWsUrl;
try {
this.ws = new WebSocket(url);
} catch (err) {
this.updateState({ state: 'error', lastError: `Failed to create WebSocket: ${err}` });
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
// Waiting for challenge from gateway
this.updateState({ state: 'challenged' });
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (event) => {
this.updateState({
state: 'error',
lastError: `WebSocket error: ${event.type}`,
});
};
this.ws.onclose = (event) => {
this.updateState({
state: 'disconnected',
lastError: event.reason || `WebSocket closed (code: ${event.code})`,
});
this.clearPendingRpcs(new Error(`WebSocket closed: ${event.code}`));
this.scheduleReconnect();
};
}
/**
* Disconnect from the Gateway WebSocket.
* Cancels reconnect timer and cleans up.
*/
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.reconnectAttempt = 0;
if (this.ws) {
this.ws.onclose = null; // prevent reconnect
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
this.clearPendingRpcs(new Error('Client disconnected'));
this.updateState({ state: 'disconnected' });
}
/**
* Check if the WebSocket is currently connected.
*/
get isConnected(): boolean {
return this.connectionState$.value.state === 'connected';
}
// ─── RPC Methods ─────────────────────────────────────────────────────
/**
* Send an RPC request and wait for the response.
* Returns a promise that resolves with the response payload
* or rejects on error/timeout.
*
* @param method RPC method name (e.g. "sessions.list", "agents.list")
* @param params Method parameters
* @param timeoutMs Optional timeout override
*/
async rpc<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs?: number
): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}
const id = `cc-${++this.rpcId}`;
const request: WsRequest = {
type: 'req',
id,
method,
params,
};
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`RPC timeout: ${method} (${timeoutMs || this.config.httpTimeoutMs}ms)`));
}, timeoutMs || this.config.httpTimeoutMs);
this.pendingRpc.set(id, {
resolve: resolve as (p: unknown) => void,
reject,
timeoutId: timeout,
});
this.ws!.send(JSON.stringify(request));
});
}
// ─── High-Level API ─────────────────────────────────────────────────
/**
* Subscribe to session change events for the current WS client.
* After subscribing, `sessions.changed` events will be emitted.
*/
async subscribeToSessions(): Promise<void> {
if (this.sessionSubscriptionsActive) return;
await this.rpc('sessions.subscribe', {});
this.sessionSubscriptionsActive = true;
}
/**
* Unsubscribe from session change events.
*/
async unsubscribeFromSessions(): Promise<void> {
if (!this.sessionSubscriptionsActive) return;
await this.rpc('sessions.unsubscribe', {});
this.sessionSubscriptionsActive = false;
}
/**
* Subscribe to message/transcript events for a specific session.
* Required before session.message and session.tool events fire.
*
* @param sessionKey Full session key, e.g. "agent:otto:telegram:direct:8787451565"
*/
async subscribeToSessionMessages(sessionKey: string): Promise<void> {
if (this.subscriptions.has(sessionKey)) return;
await this.rpc('sessions.messages.subscribe', { sessionKey });
this.subscriptions.add(sessionKey);
}
/**
* Unsubscribe from message events for a session.
*/
async unsubscribeFromSessionMessages(sessionKey: string): Promise<void> {
if (!this.subscriptions.has(sessionKey)) return;
await this.rpc('sessions.messages.unsubscribe', { sessionKey });
this.subscriptions.delete(sessionKey);
}
/**
* Fetch the current session list via WS RPC.
* More complete than the HTTP tools/invoke version.
*/
async fetchSessions(allAgents = true, activeMinutes?: number): Promise<OpenClawSession[]> {
const params: Record<string, unknown> = { allAgents };
if (activeMinutes !== undefined) {
params.activeMinutes = activeMinutes;
}
const result = await this.rpc<{ sessions: OpenClawSession[]; count: number }>(
'sessions.list',
params
);
return result.sessions || [];
}
/**
* Fetch the agent list via WS RPC.
*/
async fetchAgents(): Promise<Array<{ id: string; configured: boolean }>> {
const result = await this.rpc<{ agents: Array<{ id: string; configured: boolean }> }>(
'agents.list',
{}
);
return result.agents || [];
}
/**
* Fetch agent identity via WS RPC.
*/
async fetchAgentIdentity(agentId: string): Promise<unknown> {
return this.rpc('agent.identity.get', { agentId });
}
/**
* Get a transcript preview for a session.
*/
async fetchSessionPreview(
sessionKey: string,
limit = 20
): Promise<unknown> {
return this.rpc('sessions.preview', { sessionKey, limit });
}
/**
* Abort an active session.
*/
async abortSession(sessionKey: string): Promise<void> {
await this.rpc('sessions.abort', { sessionKey });
}
/**
* Steer (interrupt + inject message) an active session.
*/
async steerSession(sessionKey: string, message: string): Promise<void> {
await this.rpc('sessions.steer', { sessionKey, message });
}
/**
* Fetch gateway status (admin-scoped).
*/
async fetchStatus(): Promise<unknown> {
return this.rpc('status', {});
}
/**
* Fetch gateway health snapshot.
*/
async fetchHealth(): Promise<unknown> {
return this.rpc('health', {});
}
/**
* Fetch system presence.
*/
async fetchPresence(): Promise<unknown> {
return this.rpc('system-presence', {});
}
/**
* Fetch channel status for all configured channels.
*/
async fetchChannelStatus(): Promise<unknown> {
return this.rpc('channels.status', {});
}
/**
* Fetch log tail from the gateway.
*/
async fetchLogTail(
cursor?: string,
limit?: number,
maxBytes?: number
): Promise<unknown> {
const params: Record<string, unknown> = {};
if (cursor) params.cursor = cursor;
if (limit) params.limit = limit;
if (maxBytes) params.maxBytes = maxBytes;
return this.rpc('logs.tail', params);
}
// ─── Internal Handlers ──────────────────────────────────────────────
private handleMessage(data: string): void {
let frame: WsFrame;
try {
frame = JSON.parse(data);
} catch {
console.warn('[OpenClaw WS] Failed to parse frame:', data);
return;
}
switch (frame.type) {
case 'event':
this.handleEvent(frame as WsEvent);
break;
case 'res':
this.handleResponse(frame as WsResponse);
break;
default:
console.warn('[OpenClaw WS] Unknown frame type:', frame.type);
}
}
private handleEvent(event: WsEvent): void {
// Handle connect challenge specially
if (event.event === 'connect.challenge') {
this.handleChallenge(event.payload as ConnectChallengePayload);
return;
}
// Emit to all subscribers
this.rawEvent$.next(event);
}
private handleChallenge(challenge: ConnectChallengePayload): void {
this.challengeNonce = challenge.nonce;
this.updateState({ state: 'authenticating' });
// Send connect request
const connectParams: ConnectParams = {
minProtocol: this.config.protocolVersion,
maxProtocol: this.config.protocolVersion,
client: {
id: this.config.clientId,
version: this.config.clientVersion,
platform: this.config.clientPlatform,
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: {
token: this.config.authToken,
},
locale: 'en-US',
userAgent: `control-center/${this.config.clientVersion}`,
};
this.sendRequest('connect', connectParams);
}
private handleResponse(response: WsResponse): void {
// Handle hello-ok (connect response)
if (response.id && response.id.startsWith('cc-')) {
const pending = this.pendingRpc.get(response.id);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingRpc.delete(response.id);
if (response.ok) {
pending.resolve(response.payload);
} else {
pending.reject(
new Error(
`RPC error: ${response.error?.type || 'unknown'}${response.error?.message || 'no details'}`
)
);
}
return;
}
}
// Handle connect response (sent without cc- prefix)
if (response.ok && response.payload) {
const payload = response.payload as HelloOkPayload;
if (payload.type === 'hello-ok') {
this.reconnectAttempt = 0;
this.updateState({
state: 'connected',
protocol: payload.protocol,
});
// Auto-subscribe to session changes
this.subscribeToSessions().catch((err) => {
console.warn('[OpenClaw WS] Failed to subscribe to sessions:', err);
});
return;
}
}
// Handle connect error
if (!response.ok && response.error) {
this.updateState({
state: 'error',
lastError: `Connect failed: ${response.error.type}${response.error.message}`,
});
this.scheduleReconnect();
}
}
private sendRequest(method: string, params: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const request: WsRequest = {
type: 'req',
id: `rpc-${++this.rpcId}`,
method,
params,
};
this.ws.send(JSON.stringify(request));
}
private updateState(partial: Partial<WsConnectionInfo>): void {
const current = this.connectionState$.value;
this.connectionState$.next({ ...current, ...partial } as WsConnectionInfo);
}
private scheduleReconnect(): void {
if (this.reconnectAttempt >= this.config.wsMaxReconnectAttempts) {
this.updateState({
state: 'error',
lastError: `Max reconnect attempts reached (${this.config.wsMaxReconnectAttempts})`,
});
return;
}
this.reconnectAttempt++;
const delay = Math.min(
this.config.wsReconnectDelayMs * Math.pow(1.5, this.reconnectAttempt - 1),
30_000 // cap at 30s
);
this.updateState({
state: 'reconnecting',
reconnectAttempt: this.reconnectAttempt,
});
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
private clearPendingRpcs(error: Error): void {
for (const [id, pending] of this.pendingRpc.entries()) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
this.pendingRpc.clear();
}
/**
* Clean up all subscriptions and disconnect.
*/
destroy(): void {
this.disconnect();
this.rawEvent$.complete();
this.connectionState$.complete();
this.subscriptions.clear();
this.sessionSubscriptionsActive = false;
}
}

21
api-client/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}