initial commit
This commit is contained in:
32
api-client/package.json
Normal file
32
api-client/package.json
Normal 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
|
||||
}
|
||||
5
api-client/src/models/index.ts
Normal file
5
api-client/src/models/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Re-export barrel for all model types.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
426
api-client/src/models/types.ts
Normal file
426
api-client/src/models/types.ts
Normal 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 (0–100), 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',
|
||||
};
|
||||
272
api-client/src/services/http-client.ts
Normal file
272
api-client/src/services/http-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
api-client/src/utils/config.ts
Normal file
75
api-client/src/utils/config.ts
Normal 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 };
|
||||
}
|
||||
6
api-client/src/utils/index.ts
Normal file
6
api-client/src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Re-export barrel for utilities.
|
||||
*/
|
||||
|
||||
export * from './config';
|
||||
export * from './status-mapper';
|
||||
178
api-client/src/utils/status-mapper.ts
Normal file
178
api-client/src/utils/status-mapper.ts
Normal 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 0–100 progress percentage.
|
||||
* We derive an approximation:
|
||||
* - "running" session → 10–50% (early in the task, we don't know)
|
||||
* - "streaming" → 60–90% (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;
|
||||
}
|
||||
5
api-client/src/websocket/index.ts
Normal file
5
api-client/src/websocket/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Re-export barrel for WebSocket module.
|
||||
*/
|
||||
|
||||
export * from './ws-client';
|
||||
584
api-client/src/websocket/ws-client.ts
Normal file
584
api-client/src/websocket/ws-client.ts
Normal 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
21
api-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user