Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- S-3: Thread task_id (UUID) through all 6 layers (LoopEvent → StreamChatEvent → kernel-types → gateway-client → streamStore) so subtasks are matched by ID, not description string
- AgentState: Add #[serde(rename_all = "lowercase")] to fix PascalCase serialization ("Running" → "running"), update frontend matcher
- S-1: Remove unused onClose prop from ArtifactPanel + ChatArea call site
- Fix hooks/index.ts: remove orphaned useAutomationEvents re-exports (module deleted)
- Fix types/index.ts: remove orphaned automation type/value re-exports (module deleted)
- Fix ChatArea.tsx: framer-motion 12 + React 19 type compat — use createElement + explicit any return type to avoid unknown-in-JSX-child error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1287 lines
45 KiB
TypeScript
1287 lines
45 KiB
TypeScript
/**
|
|
* ZCLAW Gateway Client (Browser/Tauri side)
|
|
*
|
|
* Core WebSocket client for ZCLAW Kernel protocol.
|
|
* Handles connection management, WebSocket framing, heartbeat,
|
|
* event dispatch, and chat/stream operations.
|
|
*
|
|
* Module structure:
|
|
* - gateway-types.ts: Protocol types, stream types, ConnectionState
|
|
* - gateway-auth.ts: Device authentication (Ed25519)
|
|
* - gateway-storage.ts: URL/token persistence, normalization
|
|
* - gateway-api.ts: REST API method implementations (installed via mixin)
|
|
* - gateway-client.ts: Core client class (this file)
|
|
*/
|
|
|
|
// === Re-exports for backward compatibility ===
|
|
export type {
|
|
GatewayRequest,
|
|
GatewayError,
|
|
GatewayResponse,
|
|
GatewayEvent,
|
|
GatewayPong,
|
|
GatewayFrame,
|
|
AgentStreamDelta,
|
|
ZclawStreamEvent,
|
|
ConnectionState,
|
|
EventCallback,
|
|
} from './gateway-types';
|
|
|
|
export {
|
|
getLocalDeviceIdentity,
|
|
clearDeviceKeys,
|
|
} from './gateway-auth';
|
|
export type { LocalDeviceIdentity } from './gateway-auth';
|
|
|
|
export {
|
|
DEFAULT_GATEWAY_URL,
|
|
REST_API_URL,
|
|
FALLBACK_GATEWAY_URLS,
|
|
normalizeGatewayUrl,
|
|
isLocalhost,
|
|
getStoredGatewayUrl,
|
|
setStoredGatewayUrl,
|
|
getStoredGatewayToken,
|
|
setStoredGatewayToken,
|
|
} from './gateway-storage';
|
|
|
|
// === Internal imports ===
|
|
import type {
|
|
GatewayRequest,
|
|
GatewayFrame,
|
|
GatewayResponse,
|
|
GatewayEvent,
|
|
ZclawStreamEvent,
|
|
ConnectionState,
|
|
EventCallback,
|
|
AgentStreamDelta,
|
|
} from './gateway-types';
|
|
|
|
import {
|
|
loadDeviceKeys,
|
|
signDeviceAuth,
|
|
clearDeviceKeys,
|
|
type DeviceKeys,
|
|
} from './gateway-auth';
|
|
|
|
import {
|
|
normalizeGatewayUrl,
|
|
isLocalhost,
|
|
getStoredGatewayUrl,
|
|
getStoredGatewayToken,
|
|
} from './gateway-storage';
|
|
|
|
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
|
|
import { installApiMethods } from './gateway-api';
|
|
import { createLogger } from './logger';
|
|
import { GatewayHttpError } from './gateway-errors';
|
|
|
|
const log = createLogger('GatewayClient');
|
|
|
|
// === Security ===
|
|
|
|
/**
|
|
* Security error for invalid WebSocket connections.
|
|
* Thrown when non-localhost URLs use ws:// instead of wss://.
|
|
*/
|
|
export class SecurityError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'SecurityError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connection error for WebSocket/HTTP connection failures.
|
|
*/
|
|
export class ConnectionError extends Error {
|
|
public readonly code?: string;
|
|
public readonly recoverable: boolean;
|
|
|
|
constructor(message: string, code?: string, recoverable: boolean = true) {
|
|
super(message);
|
|
this.name = 'ConnectionError';
|
|
this.code = code;
|
|
this.recoverable = recoverable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Timeout error for request/response timeouts.
|
|
*/
|
|
export class TimeoutError extends Error {
|
|
public readonly timeout: number;
|
|
|
|
constructor(message: string, timeout: number) {
|
|
super(message);
|
|
this.name = 'TimeoutError';
|
|
this.timeout = timeout;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authentication error for handshake/token failures.
|
|
*/
|
|
export class AuthenticationError extends Error {
|
|
public readonly code?: string;
|
|
|
|
constructor(message: string, code?: string) {
|
|
super(message);
|
|
this.name = 'AuthenticationError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate WebSocket URL security.
|
|
* Ensures non-localhost connections use WSS protocol.
|
|
*
|
|
* @param url - The WebSocket URL to validate
|
|
* @throws SecurityError if non-localhost URL uses ws:// instead of wss://
|
|
*/
|
|
export function validateWebSocketSecurity(url: string): void {
|
|
if (!url.startsWith('wss://') && !isLocalhost(url)) {
|
|
throw new SecurityError(
|
|
'Non-localhost connections must use WSS protocol for security. ' +
|
|
`URL: ${url.replace(/:[^:@]+@/, ':****@')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function createIdempotencyKey(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('');
|
|
return `idem_${Date.now()}_${suffix}`;
|
|
}
|
|
|
|
// === Client ===
|
|
|
|
export class GatewayClient {
|
|
private ws: WebSocket | null = null;
|
|
private zclawWs: WebSocket | null = null; // ZCLAW stream WebSocket
|
|
private state: ConnectionState = 'disconnected';
|
|
private requestId = 0;
|
|
private pendingRequests = new Map<string, {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: unknown) => void;
|
|
timer: number;
|
|
}>();
|
|
private eventListeners = new Map<string, Set<EventCallback>>();
|
|
private reconnectAttempts = 0;
|
|
private reconnectTimer: number | null = null;
|
|
private deviceKeysPromise: Promise<DeviceKeys>;
|
|
private streamCallbacks = new Map<string, {
|
|
onDelta: (delta: string) => void;
|
|
onThinkingDelta?: (delta: string) => void;
|
|
onTool?: (tool: string, input: string, output: string) => void;
|
|
onHand?: (name: string, status: string, result?: unknown) => void;
|
|
onSubtaskStatus?: (taskId: string, description: string, status: string, detail?: string) => void;
|
|
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
|
onError: (error: string) => void;
|
|
}>();
|
|
|
|
// Options
|
|
private url: string;
|
|
private token: string;
|
|
private autoReconnect: boolean;
|
|
private reconnectInterval: number;
|
|
private requestTimeout: number;
|
|
|
|
// Heartbeat
|
|
private heartbeatInterval: number | null = null;
|
|
private heartbeatTimeout: number | null = null;
|
|
private missedHeartbeats: number = 0;
|
|
private static readonly HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
private static readonly HEARTBEAT_TIMEOUT = 10000; // 10 seconds
|
|
private static readonly MAX_MISSED_HEARTBEATS = 3;
|
|
|
|
// State change callbacks
|
|
onStateChange?: (state: ConnectionState) => void;
|
|
onLog?: (level: string, message: string) => void;
|
|
|
|
constructor(opts?: {
|
|
url?: string;
|
|
token?: string;
|
|
autoReconnect?: boolean;
|
|
reconnectInterval?: number;
|
|
requestTimeout?: number;
|
|
}) {
|
|
this.url = normalizeGatewayUrl(opts?.url || getStoredGatewayUrl());
|
|
this.token = opts?.token ?? getStoredGatewayToken();
|
|
this.autoReconnect = opts?.autoReconnect ?? true;
|
|
this.reconnectInterval = opts?.reconnectInterval || 3000;
|
|
this.requestTimeout = opts?.requestTimeout || 30000;
|
|
this.deviceKeysPromise = loadDeviceKeys();
|
|
}
|
|
|
|
updateOptions(opts?: {
|
|
url?: string;
|
|
token?: string;
|
|
autoReconnect?: boolean;
|
|
reconnectInterval?: number;
|
|
requestTimeout?: number;
|
|
}) {
|
|
if (!opts) return;
|
|
if (opts.url) {
|
|
this.url = normalizeGatewayUrl(opts.url);
|
|
}
|
|
if (opts.token !== undefined) {
|
|
this.token = opts.token;
|
|
}
|
|
if (opts.autoReconnect !== undefined) {
|
|
this.autoReconnect = opts.autoReconnect;
|
|
}
|
|
if (opts.reconnectInterval !== undefined) {
|
|
this.reconnectInterval = opts.reconnectInterval;
|
|
}
|
|
if (opts.requestTimeout !== undefined) {
|
|
this.requestTimeout = opts.requestTimeout;
|
|
}
|
|
}
|
|
|
|
getState(): ConnectionState {
|
|
return this.state;
|
|
}
|
|
|
|
// === Connection ===
|
|
|
|
/** Connect using REST API only (for ZCLAW mode) */
|
|
async connectRest(): Promise<void> {
|
|
if (this.state === 'connected') {
|
|
return;
|
|
}
|
|
this.setState('connecting');
|
|
try {
|
|
// Check if ZCLAW API is healthy
|
|
const health = await this.restGet<{ status: string; version?: string }>('/api/health');
|
|
if (health.status === 'ok') {
|
|
this.reconnectAttempts = 0;
|
|
this.setState('connected');
|
|
this.startHeartbeat(); // Start heartbeat after successful connection
|
|
this.log('info', `Connected to ZCLAW via REST API${health.version ? ` (v${health.version})` : ''}`);
|
|
this.emitEvent('connected', { version: health.version });
|
|
} else {
|
|
throw new Error('Health check failed');
|
|
}
|
|
} catch (err: unknown) {
|
|
this.setState('disconnected');
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Failed to connect to ZCLAW: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
connect(): Promise<void> {
|
|
if (this.state === 'connected' || this.state === 'connecting' || this.state === 'handshaking') {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Check if URL is for ZCLAW (port 4200 or 50051) - use REST mode
|
|
if (this.url.includes(':4200') || this.url.includes(':50051')) {
|
|
return this.connectRest();
|
|
}
|
|
|
|
// Security validation: enforce WSS for non-localhost connections
|
|
validateWebSocketSecurity(this.url);
|
|
|
|
this.autoReconnect = true;
|
|
this.setState('connecting');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const settleResolve = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
};
|
|
const settleReject = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
const handshakeTimer = window.setTimeout(() => {
|
|
this.log('error', `Handshake timed out after ${this.requestTimeout}ms`);
|
|
this.cleanup();
|
|
settleReject(new Error(`Gateway handshake timed out after ${this.requestTimeout}ms`));
|
|
}, this.requestTimeout);
|
|
|
|
try {
|
|
this.ws = new WebSocket(this.url);
|
|
|
|
this.ws.onopen = () => {
|
|
this.setState('handshaking');
|
|
};
|
|
|
|
this.ws.onmessage = (evt) => {
|
|
try {
|
|
const frame: GatewayFrame = JSON.parse(evt.data);
|
|
this.handleFrame(frame, () => {
|
|
clearTimeout(handshakeTimer);
|
|
settleResolve();
|
|
}, (error) => {
|
|
clearTimeout(handshakeTimer);
|
|
settleReject(error);
|
|
});
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
this.log('error', `Parse error: ${errorMessage}`);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = (evt) => {
|
|
const wasConnected = this.state === 'connected';
|
|
const closedDuringConnect = !wasConnected && !settled;
|
|
this.cleanup();
|
|
|
|
if (wasConnected && this.autoReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
this.emitEvent('close', { code: evt.code, reason: evt.reason });
|
|
if (closedDuringConnect) {
|
|
clearTimeout(handshakeTimer);
|
|
settleReject(new Error(evt.reason || `WebSocket closed before handshake completed (code: ${evt.code})`));
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = () => {
|
|
if (this.state === 'connecting' || this.state === 'handshaking') {
|
|
clearTimeout(handshakeTimer);
|
|
this.cleanup();
|
|
settleReject(new Error('WebSocket connection failed'));
|
|
}
|
|
};
|
|
} catch (err) {
|
|
clearTimeout(handshakeTimer);
|
|
this.cleanup();
|
|
settleReject(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
});
|
|
}
|
|
|
|
disconnect() {
|
|
this.autoReconnect = false;
|
|
this.cancelReconnect();
|
|
|
|
if (this.ws) {
|
|
this.ws.close(1000, 'Client disconnect');
|
|
}
|
|
this.cleanup();
|
|
}
|
|
|
|
// === Request/Response ===
|
|
|
|
async request(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
if (this.state !== 'connected') {
|
|
throw new Error(`Not connected (state: ${this.state})`);
|
|
}
|
|
|
|
const id = `req_${++this.requestId}`;
|
|
const frame: GatewayRequest = { type: 'req', id, method, params };
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = window.setTimeout(() => {
|
|
this.pendingRequests.delete(id);
|
|
reject(new Error(`Request ${method} timed out`));
|
|
}, this.requestTimeout);
|
|
|
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
this.send(frame);
|
|
});
|
|
}
|
|
|
|
// === High-level API ===
|
|
|
|
// Default agent ID for ZCLAW (will be set dynamically from /api/agents)
|
|
private defaultAgentId: string = '';
|
|
|
|
/** Try to fetch default agent ID from ZCLAW /api/agents endpoint */
|
|
async fetchDefaultAgentId(): Promise<string | null> {
|
|
try {
|
|
// Use /api/agents endpoint which returns array of agents
|
|
const agents = await this.restGet<Array<{ id: string; name?: string; state?: string }>>('/api/agents');
|
|
if (agents && agents.length > 0) {
|
|
// Prefer agent with state "Running", otherwise use first agent
|
|
const runningAgent = agents.find((a: { id: string; name?: string; state?: string }) => a.state === 'running');
|
|
const defaultAgent = runningAgent || agents[0];
|
|
this.defaultAgentId = defaultAgent.id;
|
|
this.log('info', `Fetched default agent from /api/agents: ${this.defaultAgentId} (${defaultAgent.name || 'unnamed'})`);
|
|
return this.defaultAgentId;
|
|
}
|
|
} catch (err) {
|
|
this.log('warn', `Failed to fetch default agent from /api/agents: ${err}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Set the default agent ID */
|
|
setDefaultAgentId(agentId: string): void {
|
|
this.defaultAgentId = agentId;
|
|
this.log('info', `Default agent set to: ${agentId}`);
|
|
}
|
|
|
|
/** Get the current default agent ID */
|
|
getDefaultAgentId(): string {
|
|
return this.defaultAgentId;
|
|
}
|
|
|
|
/** Send message to agent (ZCLAW chat API) */
|
|
async chat(message: string, opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
idempotencyKey?: string;
|
|
extraSystemPrompt?: string;
|
|
model?: string;
|
|
temperature?: number;
|
|
maxTokens?: number;
|
|
}): Promise<{ runId: string; sessionId?: string; response?: string }> {
|
|
// ZCLAW uses /api/agents/{agentId}/message endpoint
|
|
let agentId = opts?.agentId || this.defaultAgentId;
|
|
|
|
// If no agent ID, try to fetch from ZCLAW status
|
|
if (!agentId) {
|
|
await this.fetchDefaultAgentId();
|
|
agentId = this.defaultAgentId;
|
|
}
|
|
|
|
if (!agentId) {
|
|
throw new Error('No agent available. Please ensure ZCLAW has at least one agent.');
|
|
}
|
|
|
|
const result = await this.restPost<{ response?: string; input_tokens?: number; output_tokens?: number }>(`/api/agents/${agentId}/message`, {
|
|
message,
|
|
session_id: opts?.sessionKey,
|
|
});
|
|
// ZCLAW returns { response, input_tokens, output_tokens }
|
|
return {
|
|
runId: createIdempotencyKey(),
|
|
sessionId: opts?.sessionKey,
|
|
response: result.response,
|
|
};
|
|
}
|
|
|
|
/** Send message with streaming response (ZCLAW WebSocket) */
|
|
async chatStream(
|
|
message: string,
|
|
callbacks: {
|
|
onDelta: (delta: string) => void;
|
|
onThinkingDelta?: (delta: string) => void;
|
|
onTool?: (tool: string, input: string, output: string) => void;
|
|
onHand?: (name: string, status: string, result?: unknown) => void;
|
|
onSubtaskStatus?: (taskId: string, description: string, status: string, detail?: string) => void;
|
|
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
|
onError: (error: string) => void;
|
|
},
|
|
opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
thinking_enabled?: boolean;
|
|
reasoning_effort?: string;
|
|
plan_mode?: boolean;
|
|
subagent_enabled?: boolean;
|
|
}
|
|
): Promise<{ runId: string }> {
|
|
const agentId = opts?.agentId || this.defaultAgentId;
|
|
const runId = createIdempotencyKey();
|
|
const sessionId = opts?.sessionKey || crypto.randomUUID();
|
|
|
|
// If no agent ID, try to fetch from ZCLAW status (async, but we'll handle it in connectZclawStream)
|
|
if (!agentId) {
|
|
// Try to get default agent asynchronously
|
|
const chatModeOpts = {
|
|
thinking_enabled: opts?.thinking_enabled,
|
|
reasoning_effort: opts?.reasoning_effort,
|
|
plan_mode: opts?.plan_mode,
|
|
subagent_enabled: opts?.subagent_enabled,
|
|
};
|
|
this.fetchDefaultAgentId().then(() => {
|
|
const resolvedAgentId = this.defaultAgentId;
|
|
if (resolvedAgentId) {
|
|
this.streamCallbacks.set(runId, callbacks);
|
|
this.connectZclawStream(resolvedAgentId, runId, sessionId, message, chatModeOpts);
|
|
} else {
|
|
callbacks.onError('No agent available. Please ensure ZCLAW has at least one agent.');
|
|
callbacks.onComplete();
|
|
}
|
|
}).catch((err) => {
|
|
callbacks.onError(`Failed to get agent: ${err}`);
|
|
callbacks.onComplete();
|
|
});
|
|
return { runId };
|
|
}
|
|
|
|
// Store callbacks for this run
|
|
this.streamCallbacks.set(runId, callbacks);
|
|
|
|
// Connect to ZCLAW WebSocket if not connected
|
|
this.connectZclawStream(agentId, runId, sessionId, message, {
|
|
thinking_enabled: opts?.thinking_enabled,
|
|
reasoning_effort: opts?.reasoning_effort,
|
|
plan_mode: opts?.plan_mode,
|
|
subagent_enabled: opts?.subagent_enabled,
|
|
});
|
|
|
|
return { runId };
|
|
}
|
|
|
|
/** Connect to ZCLAW streaming WebSocket */
|
|
private connectZclawStream(
|
|
agentId: string,
|
|
runId: string,
|
|
sessionId: string,
|
|
message: string,
|
|
chatModeOpts?: {
|
|
thinking_enabled?: boolean;
|
|
reasoning_effort?: string;
|
|
plan_mode?: boolean;
|
|
subagent_enabled?: boolean;
|
|
}
|
|
): void {
|
|
// Close existing connection if any
|
|
if (this.zclawWs && this.zclawWs.readyState !== WebSocket.CLOSED) {
|
|
this.zclawWs.close();
|
|
}
|
|
|
|
// Build WebSocket URL
|
|
// In dev mode, use Vite proxy; in production, use direct connection
|
|
let wsUrl: string;
|
|
if (typeof window !== 'undefined' && window.location.port === '1420') {
|
|
// Dev mode: use Vite proxy with relative path
|
|
wsUrl = `ws://${window.location.host}/api/agents/${agentId}/ws`;
|
|
} else {
|
|
// Production: extract from stored URL
|
|
const httpUrl = this.getRestBaseUrl();
|
|
wsUrl = httpUrl.replace(/^http/, 'ws') + `/api/agents/${agentId}/ws`;
|
|
}
|
|
|
|
this.log('info', `Connecting to ZCLAW stream: ${wsUrl}`);
|
|
|
|
try {
|
|
this.zclawWs = new WebSocket(wsUrl);
|
|
|
|
this.zclawWs.onopen = () => {
|
|
this.log('info', 'ZCLAW WebSocket connected');
|
|
// Send chat message using ZCLAW actual protocol
|
|
const chatRequest: Record<string, unknown> = {
|
|
type: 'message',
|
|
content: message,
|
|
session_id: sessionId,
|
|
};
|
|
if (chatModeOpts?.thinking_enabled !== undefined) {
|
|
chatRequest.thinking_enabled = chatModeOpts.thinking_enabled;
|
|
}
|
|
if (chatModeOpts?.reasoning_effort !== undefined) {
|
|
chatRequest.reasoning_effort = chatModeOpts.reasoning_effort;
|
|
}
|
|
if (chatModeOpts?.plan_mode !== undefined) {
|
|
chatRequest.plan_mode = chatModeOpts.plan_mode;
|
|
}
|
|
if (chatModeOpts?.subagent_enabled !== undefined) {
|
|
chatRequest.subagent_enabled = chatModeOpts.subagent_enabled;
|
|
}
|
|
this.zclawWs?.send(JSON.stringify(chatRequest));
|
|
};
|
|
|
|
this.zclawWs.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handleZclawStreamEvent(runId, data, sessionId);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
this.log('error', `Failed to parse stream event: ${errorMessage}`);
|
|
}
|
|
};
|
|
|
|
this.zclawWs.onerror = (_event) => {
|
|
this.log('error', 'ZCLAW WebSocket error');
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError('WebSocket connection failed');
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
};
|
|
|
|
this.zclawWs.onclose = (event) => {
|
|
this.log('info', `ZCLAW WebSocket closed: ${event.code} ${event.reason}`);
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
if (event.code !== 1000) {
|
|
callbacks.onError(`Connection closed: ${event.reason || 'unknown'}`);
|
|
} else {
|
|
// Normal closure — ensure stream is completed even if no done event was sent
|
|
callbacks.onComplete();
|
|
}
|
|
}
|
|
this.streamCallbacks.delete(runId);
|
|
this.zclawWs = null;
|
|
};
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
this.log('error', `Failed to create WebSocket: ${errorMessage}`);
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError(errorMessage);
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Handle ZCLAW stream events */
|
|
private handleZclawStreamEvent(runId: string, data: ZclawStreamEvent, sessionId: string): void {
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (!callbacks) return;
|
|
|
|
switch (data.type) {
|
|
// ZCLAW actual event types
|
|
case 'text_delta':
|
|
// Stream delta content
|
|
if (data.content) {
|
|
callbacks.onDelta(data.content);
|
|
}
|
|
break;
|
|
|
|
case 'thinking_delta':
|
|
// Extended thinking delta
|
|
if (data.content && callbacks.onThinkingDelta) {
|
|
callbacks.onThinkingDelta(data.content);
|
|
}
|
|
break;
|
|
|
|
case 'subtask_status':
|
|
// Sub-agent task status update
|
|
if (callbacks.onSubtaskStatus && data.description) {
|
|
callbacks.onSubtaskStatus(data.task_id || data.description, data.description, data.status || '', data.detail);
|
|
}
|
|
break;
|
|
|
|
case 'phase':
|
|
// Phase change: streaming | done
|
|
if (data.phase === 'done') {
|
|
const inputTokens = typeof data.input_tokens === 'number' ? data.input_tokens : undefined;
|
|
const outputTokens = typeof data.output_tokens === 'number' ? data.output_tokens : undefined;
|
|
callbacks.onComplete(inputTokens, outputTokens);
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.zclawWs) {
|
|
this.zclawWs.close(1000, 'Stream complete');
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'response':
|
|
// Final response with tokens info
|
|
if (data.content) {
|
|
// Forward the full response content via onDelta
|
|
// This handles non-streaming responses from the server
|
|
callbacks.onDelta(data.content);
|
|
}
|
|
// Mark complete if phase done wasn't sent
|
|
{
|
|
const inputTokens = typeof data.input_tokens === 'number' ? data.input_tokens : undefined;
|
|
const outputTokens = typeof data.output_tokens === 'number' ? data.output_tokens : undefined;
|
|
callbacks.onComplete(inputTokens, outputTokens);
|
|
}
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.zclawWs) {
|
|
this.zclawWs.close(1000, 'Stream complete');
|
|
}
|
|
break;
|
|
|
|
case 'typing':
|
|
// Typing indicator: { state: 'start' | 'stop' }
|
|
// Can be used for UI feedback
|
|
break;
|
|
|
|
case 'tool_call':
|
|
// Tool call event
|
|
if (callbacks.onTool && data.tool) {
|
|
callbacks.onTool(data.tool, JSON.stringify(data.input || {}), data.output || '');
|
|
}
|
|
break;
|
|
|
|
case 'tool_result':
|
|
if (callbacks.onTool && data.tool) {
|
|
callbacks.onTool(data.tool, '', String(data.result || data.output || ''));
|
|
}
|
|
break;
|
|
|
|
case 'hand':
|
|
if (callbacks.onHand && data.hand_name) {
|
|
callbacks.onHand(data.hand_name, data.hand_status || 'triggered', data.hand_result);
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
callbacks.onError(data.message || data.code || data.content || 'Unknown error');
|
|
this.streamCallbacks.delete(runId);
|
|
if (this.zclawWs) {
|
|
this.zclawWs.close(1011, 'Error');
|
|
}
|
|
break;
|
|
|
|
case 'connected':
|
|
// Connection established
|
|
this.log('info', `ZCLAW agent connected: ${data.agent_id}`);
|
|
break;
|
|
|
|
case 'agents_updated':
|
|
// Agents list updated
|
|
this.log('debug', 'Agents list updated');
|
|
break;
|
|
|
|
default:
|
|
// Emit unknown events for debugging
|
|
this.log('debug', `Stream event: ${data.type}`);
|
|
}
|
|
|
|
// Also emit to general 'agent' event listeners
|
|
this.emitEvent('agent', {
|
|
stream: data.type === 'text_delta' ? 'assistant' : data.type,
|
|
delta: data.content,
|
|
content: data.content,
|
|
runId,
|
|
sessionId,
|
|
...data,
|
|
});
|
|
}
|
|
|
|
/** Cancel an ongoing stream */
|
|
cancelStream(runId: string): void {
|
|
const callbacks = this.streamCallbacks.get(runId);
|
|
if (callbacks) {
|
|
callbacks.onError('Stream cancelled');
|
|
this.streamCallbacks.delete(runId);
|
|
}
|
|
if (this.zclawWs && this.zclawWs.readyState === WebSocket.OPEN) {
|
|
this.zclawWs.close(1000, 'User cancelled');
|
|
}
|
|
}
|
|
|
|
// === REST API Helpers (ZCLAW) ===
|
|
|
|
public getRestBaseUrl(): string {
|
|
// In browser dev mode, use Vite proxy (empty string = relative path)
|
|
// In production Tauri, extract HTTP URL from WebSocket URL
|
|
if (typeof window !== 'undefined' && window.location.port === '1420') {
|
|
// Dev mode: use Vite proxy (requests go to /api/* which Vite proxies to backend)
|
|
return '';
|
|
}
|
|
// Production: extract HTTP URL from WebSocket URL
|
|
const wsUrl = this.url;
|
|
return wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, '');
|
|
}
|
|
|
|
public async restGet<T>(path: string): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`);
|
|
if (!response.ok) {
|
|
const errorBody = await response.text().catch(() => '');
|
|
throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
public async restPost<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const url = `${baseUrl}${path}`;
|
|
log.debug(`POST ${url}`, body);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text().catch(() => '');
|
|
log.error(`POST ${url} failed: ${response.status} ${response.statusText}`, errorBody);
|
|
throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody);
|
|
}
|
|
|
|
const result = await response.json();
|
|
log.debug(`POST ${url} response:`, result);
|
|
return result;
|
|
}
|
|
|
|
public async restPut<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
public async restDelete<T>(path: string): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
public async restPatch<T>(path: string, body?: unknown): Promise<T> {
|
|
const baseUrl = this.getRestBaseUrl();
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`REST API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// === Event Subscription ===
|
|
|
|
/** Subscribe to a Gateway event (e.g., 'agent', 'chat', 'heartbeat') */
|
|
on(event: string, callback: EventCallback): () => void {
|
|
if (!this.eventListeners.has(event)) {
|
|
this.eventListeners.set(event, new Set());
|
|
}
|
|
this.eventListeners.get(event)!.add(callback);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.eventListeners.get(event)?.delete(callback);
|
|
};
|
|
}
|
|
|
|
/** Subscribe to agent stream events */
|
|
onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void {
|
|
return this.on('agent', (payload: unknown) => {
|
|
callback(payload as AgentStreamDelta);
|
|
});
|
|
}
|
|
|
|
// === Internal ===
|
|
|
|
private handleFrame(frame: GatewayFrame, connectResolve?: () => void, connectReject?: (error: Error) => void) {
|
|
// Handle pong responses for heartbeat
|
|
if (frame.type === 'pong') {
|
|
this.handlePong();
|
|
return;
|
|
}
|
|
|
|
if (frame.type === 'event') {
|
|
this.handleEvent(frame, connectResolve, connectReject);
|
|
} else if (frame.type === 'res') {
|
|
this.handleResponse(frame);
|
|
}
|
|
}
|
|
|
|
private handleEvent(event: GatewayEvent, connectResolve?: () => void, connectReject?: (error: Error) => void) {
|
|
// Handle connect challenge
|
|
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
|
|
const payload = event.payload as { nonce?: string } | undefined;
|
|
this.performHandshake(payload?.nonce || '', connectResolve, connectReject);
|
|
return;
|
|
}
|
|
|
|
// Dispatch to listeners
|
|
this.emitEvent(event.event, event.payload);
|
|
}
|
|
|
|
private async performHandshake(challengeNonce: string | undefined, connectResolve?: () => void, connectReject?: (error: Error) => void) {
|
|
if (!challengeNonce) {
|
|
this.log('error', 'No challenge nonce received');
|
|
connectReject?.(new Error('Handshake failed: no challenge nonce'));
|
|
return;
|
|
}
|
|
const connectId = `connect_${Date.now()}`;
|
|
// Use a valid client ID from GATEWAY_CLIENT_ID_SET
|
|
// Valid IDs: gateway-client, cli, webchat, node-host, test
|
|
// 'cli' is for control UI / command-line clients
|
|
const clientId = 'cli';
|
|
// Valid modes: cli, webchat, backend, node
|
|
// 'cli' is for command-line/Control UI clients
|
|
const clientMode = 'cli';
|
|
const role = 'operator';
|
|
const scopes = ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.pairing'];
|
|
|
|
// Debug: log token status
|
|
this.log('debug', `Handshake token: ${this.token ? `${this.token.substring(0, 8)}... (${this.token.length} chars)` : '(empty)'}`);
|
|
|
|
try {
|
|
const deviceKeys = await this.deviceKeysPromise;
|
|
|
|
// Debug: log device auth details
|
|
this.log('debug', `Device auth: deviceId=${deviceKeys.deviceId.substring(0, 8)}..., nonce=${challengeNonce.substring(0, 8)}...`);
|
|
|
|
const { signature, signedAt } = signDeviceAuth({
|
|
clientId,
|
|
clientMode,
|
|
deviceId: deviceKeys.deviceId,
|
|
nonce: challengeNonce,
|
|
role,
|
|
scopes,
|
|
secretKey: deviceKeys.secretKey,
|
|
token: this.token,
|
|
});
|
|
|
|
// Debug: log signature details
|
|
this.log('debug', `Signature created: signedAt=${signedAt}, sig=${signature.substring(0, 16)}...`);
|
|
|
|
const connectReq: GatewayRequest = {
|
|
type: 'req',
|
|
id: connectId,
|
|
method: 'connect',
|
|
params: {
|
|
minProtocol: 3,
|
|
maxProtocol: 3,
|
|
client: {
|
|
id: clientId,
|
|
version: '0.1.0',
|
|
platform: this.detectPlatform(),
|
|
mode: clientMode,
|
|
},
|
|
role,
|
|
scopes,
|
|
|
|
auth: this.token ? { token: this.token } : {},
|
|
locale: 'zh-CN',
|
|
userAgent: 'zclaw-tauri/0.1.0',
|
|
device: {
|
|
id: deviceKeys.deviceId,
|
|
publicKey: deviceKeys.publicKeyBase64,
|
|
signature,
|
|
signedAt,
|
|
nonce: challengeNonce,
|
|
},
|
|
},
|
|
};
|
|
|
|
const originalHandler = this.ws!.onmessage;
|
|
this.ws!.onmessage = (evt) => {
|
|
try {
|
|
const frame = JSON.parse(evt.data);
|
|
if (frame.type === 'res' && frame.id === connectId) {
|
|
this.ws!.onmessage = originalHandler;
|
|
if (frame.ok) {
|
|
this.setState('connected');
|
|
this.reconnectAttempts = 0;
|
|
this.startHeartbeat(); // Start heartbeat after successful connection
|
|
this.emitEvent('connected', frame.payload);
|
|
this.log('info', 'Connected to Gateway');
|
|
connectResolve?.();
|
|
} else {
|
|
const errorObj = frame.error;
|
|
const errorMessage = errorObj?.message || errorObj?.code || JSON.stringify(errorObj);
|
|
const error = new Error(`Handshake failed: ${errorMessage}`);
|
|
this.log('error', error.message);
|
|
|
|
// Check for signature-related errors and clear device keys if needed
|
|
if (errorMessage.includes('signature') || errorMessage.includes('device')) {
|
|
this.log('warn', 'Device signature failed, clearing cached keys for retry');
|
|
clearDeviceKeys();
|
|
}
|
|
|
|
this.cleanup();
|
|
connectReject?.(error);
|
|
}
|
|
} else {
|
|
originalHandler?.call(this.ws!, evt);
|
|
}
|
|
} catch (e) {
|
|
log.debug('Parse error in handshake response handler', { error: e });
|
|
}
|
|
};
|
|
|
|
this.send(connectReq);
|
|
} catch (err: unknown) {
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
this.log('error', error.message);
|
|
this.cleanup();
|
|
connectReject?.(error);
|
|
}
|
|
}
|
|
|
|
private handleResponse(res: GatewayResponse) {
|
|
const pending = this.pendingRequests.get(res.id);
|
|
if (pending) {
|
|
clearTimeout(pending.timer);
|
|
this.pendingRequests.delete(res.id);
|
|
if (res.ok) {
|
|
pending.resolve(res.payload);
|
|
} else {
|
|
pending.reject(new Error(JSON.stringify(res.error)));
|
|
}
|
|
}
|
|
}
|
|
|
|
private send(frame: GatewayFrame) {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(frame));
|
|
}
|
|
}
|
|
|
|
private emitEvent(event: string, payload: unknown) {
|
|
const listeners = this.eventListeners.get(event);
|
|
if (listeners) {
|
|
for (const cb of listeners) {
|
|
try { cb(payload); } catch (e) { log.debug('Event listener error', { error: e }); }
|
|
}
|
|
}
|
|
// Also emit wildcard
|
|
const wildcardListeners = this.eventListeners.get('*');
|
|
if (wildcardListeners) {
|
|
for (const cb of wildcardListeners) {
|
|
try { cb({ event, payload }); } catch (e) { log.debug('Wildcard event listener error', { error: e }); }
|
|
}
|
|
}
|
|
}
|
|
|
|
private setState(state: ConnectionState) {
|
|
this.state = state;
|
|
this.onStateChange?.(state);
|
|
this.emitEvent('state', state);
|
|
}
|
|
|
|
private cleanup() {
|
|
// Stop heartbeat on cleanup
|
|
this.stopHeartbeat();
|
|
|
|
for (const [, pending] of this.pendingRequests) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new Error('Connection closed'));
|
|
}
|
|
this.pendingRequests.clear();
|
|
|
|
if (this.ws) {
|
|
this.ws.onopen = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.onclose = null;
|
|
this.ws.onerror = null;
|
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
try { this.ws.close(); } catch (e) { log.debug('WebSocket close failed during cleanup', { error: e }); }
|
|
}
|
|
this.ws = null;
|
|
}
|
|
|
|
this.setState('disconnected');
|
|
}
|
|
|
|
// === Heartbeat Methods ===
|
|
|
|
/**
|
|
* Start heartbeat to keep connection alive.
|
|
* Called after successful connection.
|
|
*/
|
|
private startHeartbeat(): void {
|
|
this.stopHeartbeat();
|
|
this.missedHeartbeats = 0;
|
|
|
|
this.heartbeatInterval = window.setInterval(() => {
|
|
this.sendHeartbeat();
|
|
}, GatewayClient.HEARTBEAT_INTERVAL);
|
|
|
|
this.log('debug', 'Heartbeat started');
|
|
}
|
|
|
|
/**
|
|
* Stop heartbeat.
|
|
* Called on cleanup or disconnect.
|
|
*/
|
|
private stopHeartbeat(): void {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
if (this.heartbeatTimeout) {
|
|
clearTimeout(this.heartbeatTimeout);
|
|
this.heartbeatTimeout = null;
|
|
}
|
|
this.log('debug', 'Heartbeat stopped');
|
|
}
|
|
|
|
/**
|
|
* Send a ping heartbeat to the server.
|
|
*/
|
|
private sendHeartbeat(): void {
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
this.log('debug', 'Skipping heartbeat - WebSocket not open');
|
|
return;
|
|
}
|
|
|
|
this.missedHeartbeats++;
|
|
if (this.missedHeartbeats > GatewayClient.MAX_MISSED_HEARTBEATS) {
|
|
this.log('warn', `Max missed heartbeats (${GatewayClient.MAX_MISSED_HEARTBEATS}), reconnecting`);
|
|
this.stopHeartbeat();
|
|
this.ws.close(4000, 'Heartbeat timeout');
|
|
return;
|
|
}
|
|
|
|
// Send ping frame
|
|
try {
|
|
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
this.log('debug', `Ping sent (missed: ${this.missedHeartbeats})`);
|
|
|
|
// Set timeout for pong
|
|
this.heartbeatTimeout = window.setTimeout(() => {
|
|
this.log('warn', 'Heartbeat pong timeout');
|
|
// Don't reconnect immediately, let the next heartbeat check
|
|
}, GatewayClient.HEARTBEAT_TIMEOUT);
|
|
} catch (error) {
|
|
this.log('error', `Failed to send heartbeat: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle pong response from server.
|
|
*/
|
|
private handlePong(): void {
|
|
this.missedHeartbeats = 0;
|
|
if (this.heartbeatTimeout) {
|
|
clearTimeout(this.heartbeatTimeout);
|
|
this.heartbeatTimeout = null;
|
|
}
|
|
this.log('debug', 'Pong received, heartbeat reset');
|
|
}
|
|
|
|
private static readonly MAX_RECONNECT_ATTEMPTS = 10;
|
|
|
|
private scheduleReconnect() {
|
|
if (this.reconnectAttempts >= GatewayClient.MAX_RECONNECT_ATTEMPTS) {
|
|
this.log('error', `Max reconnect attempts (${GatewayClient.MAX_RECONNECT_ATTEMPTS}) reached. Please reconnect manually.`);
|
|
this.setState('disconnected');
|
|
this.emitEvent('reconnect_failed', {
|
|
attempts: this.reconnectAttempts,
|
|
maxAttempts: GatewayClient.MAX_RECONNECT_ATTEMPTS
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
this.setState('reconnecting');
|
|
const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
|
|
|
|
this.log('info', `Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
|
|
|
|
// Emit reconnecting event for UI
|
|
this.emitEvent('reconnecting', {
|
|
attempt: this.reconnectAttempts,
|
|
delay,
|
|
maxAttempts: GatewayClient.MAX_RECONNECT_ATTEMPTS
|
|
});
|
|
|
|
this.reconnectTimer = window.setTimeout(async () => {
|
|
try {
|
|
await this.connect();
|
|
} catch (e) {
|
|
/* close handler will trigger another reconnect */
|
|
this.log('warn', `Reconnect attempt ${this.reconnectAttempts} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
private cancelReconnect() {
|
|
if (this.reconnectTimer !== null) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
private detectPlatform(): string {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
if (ua.includes('win')) return 'windows';
|
|
if (ua.includes('mac')) return 'macos';
|
|
return 'linux';
|
|
}
|
|
|
|
private log(level: string, message: string) {
|
|
this.onLog?.(level, message);
|
|
}
|
|
}
|
|
|
|
// Install REST API methods from gateway-api.ts onto GatewayClient prototype
|
|
installApiMethods(GatewayClient);
|
|
|
|
// Singleton instance
|
|
let _client: GatewayClient | null = null;
|
|
|
|
export function getGatewayClient(opts?: ConstructorParameters<typeof GatewayClient>[0]): GatewayClient {
|
|
if (!_client) {
|
|
_client = new GatewayClient(opts);
|
|
} else if (opts) {
|
|
_client.updateOptions(opts);
|
|
}
|
|
return _client;
|
|
}
|
|
|
|
// === API Method Type Declarations ===
|
|
// These methods are installed at runtime by installApiMethods() in gateway-api.ts.
|
|
// We declare them here so TypeScript knows they exist on GatewayClient.
|
|
export interface GatewayClient {
|
|
health(): Promise<any>;
|
|
status(): Promise<any>;
|
|
listClones(): Promise<any>;
|
|
createClone(opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string; workspaceDir?: string; restrictFiles?: boolean; privacyOptIn?: boolean; userName?: string; userRole?: string; emoji?: string; personality?: string; communicationStyle?: string; notes?: string }): Promise<any>;
|
|
updateClone(id: string, updates: Record<string, any>): Promise<any>;
|
|
deleteClone(id: string): Promise<any>;
|
|
getUsageStats(): Promise<any>;
|
|
getSessionStats(): Promise<any>;
|
|
getWorkspaceInfo(): Promise<any>;
|
|
getPluginStatus(): Promise<any>;
|
|
getQuickConfig(): Promise<any>;
|
|
saveQuickConfig(config: Record<string, any>): Promise<any>;
|
|
listSkills(): Promise<any>;
|
|
getSkill(id: string): Promise<any>;
|
|
createSkill(skill: { name: string; description?: string; triggers: Array<{ type: string; pattern?: string }>; actions: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }): Promise<any>;
|
|
updateSkill(id: string, updates: { name?: string; description?: string; triggers?: Array<{ type: string; pattern?: string }>; actions?: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }): Promise<any>;
|
|
deleteSkill(id: string): Promise<any>;
|
|
listChannels(): Promise<any>;
|
|
getChannel(id: string): Promise<any>;
|
|
createChannel(channel: { type: string; name: string; config: Record<string, unknown>; enabled?: boolean }): Promise<any>;
|
|
updateChannel(id: string, updates: { name?: string; config?: Record<string, unknown>; enabled?: boolean }): Promise<any>;
|
|
deleteChannel(id: string): Promise<any>;
|
|
getFeishuStatus(): Promise<any>;
|
|
listScheduledTasks(): Promise<any>;
|
|
createScheduledTask(task: { name: string; schedule: string; scheduleType: 'cron' | 'interval' | 'once'; target?: { type: 'agent' | 'hand' | 'workflow'; id: string }; description?: string; enabled?: boolean }): Promise<{ id: string; name: string; schedule: string; status: string }>;
|
|
deleteScheduledTask(id: string): Promise<void>;
|
|
toggleScheduledTask(id: string, enabled: boolean): Promise<{ id: string; enabled: boolean }>;
|
|
listHands(): Promise<{ hands: { id?: string; name: string; description?: string; status?: string; requirements_met?: boolean; category?: string; icon?: string; tool_count?: number; tools?: string[]; metric_count?: number; metrics?: string[] }[] }>;
|
|
getHand(name: string): Promise<any>;
|
|
triggerHand(name: string, params?: Record<string, unknown>, autonomyLevel?: string): Promise<{ runId: string; status: string }>;
|
|
getHandStatus(name: string, runId: string): Promise<{ status: string; result?: unknown }>;
|
|
approveHand(name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }>;
|
|
cancelHand(name: string, runId: string): Promise<{ status: string }>;
|
|
listHandRuns(name: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: { runId: string; status: string; startedAt: string }[] }>;
|
|
listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number }[] }>;
|
|
getWorkflow(id: string): Promise<{ id: string; name: string; steps: unknown[] }>;
|
|
executeWorkflow(id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string }>;
|
|
getWorkflowRun(workflowId: string, runId: string): Promise<{ status: string; step: string; result?: unknown }>;
|
|
listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: Array<{ runId: string; status: string; startedAt: string; completedAt?: string; step?: string; result?: unknown; error?: string }> }>;
|
|
cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }>;
|
|
createWorkflow(workflow: { name: string; description?: string; steps: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> }): Promise<{ id: string; name: string }>;
|
|
updateWorkflow(id: string, updates: { name?: string; description?: string; steps?: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> }): Promise<{ id: string; name: string }>;
|
|
deleteWorkflow(id: string): Promise<{ status: string }>;
|
|
listSessions(opts?: { limit?: number; offset?: number }): Promise<{ sessions: Array<{ id: string; agent_id: string; created_at: string; updated_at?: string; message_count?: number; status?: 'active' | 'archived' | 'expired' }> }>;
|
|
getSession(sessionId: string): Promise<any>;
|
|
createSession(opts: { agent_id: string; metadata?: Record<string, unknown> }): Promise<{ id: string; agent_id: string; created_at: string }>;
|
|
deleteSession(sessionId: string): Promise<{ status: string }>;
|
|
getSessionMessages(sessionId: string, opts?: { limit?: number; offset?: number }): Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant' | 'system'; content: string; created_at: string; tokens?: { input?: number; output?: number } }> }>;
|
|
listTriggers(): Promise<{ triggers: { id: string; type: string; enabled: boolean }[] }>;
|
|
getTrigger(id: string): Promise<any>;
|
|
createTrigger(trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }): Promise<{ id: string }>;
|
|
updateTrigger(id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }): Promise<{ id: string }>;
|
|
deleteTrigger(id: string): Promise<{ status: string }>;
|
|
getAuditLogs(opts?: { limit?: number; offset?: number }): Promise<{ logs: unknown[] }>;
|
|
verifyAuditLogChain(logId: string): Promise<{ valid: boolean; chain_depth?: number; root_hash?: string; broken_at_index?: number }>;
|
|
getSecurityStatus(): Promise<{ layers: { name: string; enabled: boolean }[] }>;
|
|
getCapabilities(): Promise<{ capabilities: string[] }>;
|
|
listApprovals(status?: string): Promise<{ approvals: { id: string; hand_name: string; run_id: string; status: string; requested_at: string; requested_by?: string; reason?: string; action?: string; params?: Record<string, unknown>; responded_at?: string; responded_by?: string; response_reason?: string }[] }>;
|
|
respondToApproval(approvalId: string, approved: boolean, reason?: string): Promise<{ status: string }>;
|
|
listModels(): Promise<{ models: GatewayModelChoice[] }>;
|
|
getConfig(): Promise<GatewayConfigSnapshot | Record<string, any>>;
|
|
applyConfig(raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise<any>;
|
|
}
|