@castlekit/castle 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/bin/castle.js +94 -0
- package/install.sh +722 -0
- package/next.config.ts +7 -0
- package/package.json +54 -5
- package/postcss.config.mjs +7 -0
- package/src/app/api/avatars/[id]/route.ts +75 -0
- package/src/app/api/openclaw/agents/route.ts +107 -0
- package/src/app/api/openclaw/config/route.ts +94 -0
- package/src/app/api/openclaw/events/route.ts +96 -0
- package/src/app/api/openclaw/logs/route.ts +59 -0
- package/src/app/api/openclaw/ping/route.ts +68 -0
- package/src/app/api/openclaw/restart/route.ts +65 -0
- package/src/app/api/openclaw/sessions/route.ts +62 -0
- package/src/app/globals.css +286 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +269 -0
- package/src/app/ui-kit/page.tsx +684 -0
- package/src/cli/onboarding.ts +576 -0
- package/src/components/dashboard/agent-status.tsx +107 -0
- package/src/components/dashboard/glass-card.tsx +28 -0
- package/src/components/dashboard/goal-widget.tsx +174 -0
- package/src/components/dashboard/greeting-widget.tsx +78 -0
- package/src/components/dashboard/index.ts +7 -0
- package/src/components/dashboard/stat-widget.tsx +61 -0
- package/src/components/dashboard/stock-widget.tsx +164 -0
- package/src/components/dashboard/weather-widget.tsx +68 -0
- package/src/components/icons/castle-icon.tsx +21 -0
- package/src/components/kanban/index.ts +3 -0
- package/src/components/kanban/kanban-board.tsx +391 -0
- package/src/components/kanban/kanban-card.tsx +137 -0
- package/src/components/kanban/kanban-column.tsx +98 -0
- package/src/components/layout/index.ts +4 -0
- package/src/components/layout/page-header.tsx +20 -0
- package/src/components/layout/sidebar.tsx +128 -0
- package/src/components/layout/theme-toggle.tsx +59 -0
- package/src/components/layout/user-menu.tsx +72 -0
- package/src/components/ui/alert.tsx +72 -0
- package/src/components/ui/avatar.tsx +87 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +107 -0
- package/src/components/ui/checkbox.tsx +56 -0
- package/src/components/ui/clock.tsx +171 -0
- package/src/components/ui/dialog.tsx +105 -0
- package/src/components/ui/index.ts +34 -0
- package/src/components/ui/input.tsx +112 -0
- package/src/components/ui/option-card.tsx +151 -0
- package/src/components/ui/progress.tsx +103 -0
- package/src/components/ui/radio.tsx +109 -0
- package/src/components/ui/select.tsx +46 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/tabs.tsx +132 -0
- package/src/components/ui/toggle-group.tsx +85 -0
- package/src/components/ui/toggle.tsx +78 -0
- package/src/components/ui/tooltip.tsx +145 -0
- package/src/components/ui/uptime.tsx +106 -0
- package/src/lib/config.ts +195 -0
- package/src/lib/gateway-connection.ts +391 -0
- package/src/lib/hooks/use-openclaw.ts +163 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export type UptimeStatus = "loading" | "operational" | "degraded" | "partial" | "major" | "maintenance";
|
|
6
|
+
|
|
7
|
+
export interface UptimeProps {
|
|
8
|
+
title: string;
|
|
9
|
+
status: UptimeStatus;
|
|
10
|
+
uptimePercent?: number;
|
|
11
|
+
message?: string;
|
|
12
|
+
data?: number[];
|
|
13
|
+
labels?: string[];
|
|
14
|
+
barCount?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const statusConfig: Record<UptimeStatus, { label: string; color: string; dot: string }> = {
|
|
19
|
+
loading: { label: "Checking...", color: "bg-foreground/5 text-foreground-secondary", dot: "bg-foreground/30" },
|
|
20
|
+
operational: { label: "Operational", color: "bg-success/10 text-success", dot: "bg-success" },
|
|
21
|
+
degraded: { label: "Degraded", color: "bg-warning/10 text-warning", dot: "bg-warning" },
|
|
22
|
+
partial: { label: "Partial Outage", color: "bg-warning/10 text-warning", dot: "bg-warning" },
|
|
23
|
+
major: { label: "Major Outage", color: "bg-error/10 text-error", dot: "bg-error" },
|
|
24
|
+
maintenance: { label: "Maintenance", color: "bg-info/10 text-info", dot: "bg-info" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function getBarColor(value: number): string {
|
|
28
|
+
if (value < 0) return "bg-foreground/10";
|
|
29
|
+
if (value >= 99) return "bg-success";
|
|
30
|
+
if (value >= 95) return "bg-success/70";
|
|
31
|
+
if (value >= 90) return "bg-warning";
|
|
32
|
+
if (value >= 50) return "bg-warning/70";
|
|
33
|
+
return "bg-error";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Uptime({
|
|
37
|
+
title,
|
|
38
|
+
status,
|
|
39
|
+
message,
|
|
40
|
+
data = [],
|
|
41
|
+
labels = [],
|
|
42
|
+
barCount = 45,
|
|
43
|
+
className,
|
|
44
|
+
}: UptimeProps) {
|
|
45
|
+
const config = statusConfig[status];
|
|
46
|
+
|
|
47
|
+
const bars = data === undefined
|
|
48
|
+
? null
|
|
49
|
+
: data.length > 0
|
|
50
|
+
? data.slice(-barCount)
|
|
51
|
+
: Array(barCount).fill(-1);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={cn("w-full", className)}>
|
|
55
|
+
<div className="flex items-center justify-between mb-6">
|
|
56
|
+
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
|
57
|
+
{status === "loading" ? (
|
|
58
|
+
<span className="inline-flex items-center gap-2 px-3 py-1 text-sm text-foreground-secondary">
|
|
59
|
+
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
|
60
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
61
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
62
|
+
</svg>
|
|
63
|
+
</span>
|
|
64
|
+
) : (
|
|
65
|
+
<span className={cn(
|
|
66
|
+
"inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium transition-opacity duration-300",
|
|
67
|
+
config.color
|
|
68
|
+
)}>
|
|
69
|
+
<span className={cn("h-2 w-2 rounded-full", config.dot)} />
|
|
70
|
+
{config.label}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="flex items-center justify-between mb-3">
|
|
76
|
+
<span className="text-sm text-foreground-secondary">Uptime</span>
|
|
77
|
+
<span className="text-sm text-foreground-secondary" suppressHydrationWarning>
|
|
78
|
+
{message}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex justify-between mb-2 h-8">
|
|
83
|
+
{bars?.map((value, i) => (
|
|
84
|
+
<div
|
|
85
|
+
key={i}
|
|
86
|
+
className={cn(
|
|
87
|
+
"h-8 w-1 rounded-full",
|
|
88
|
+
getBarColor(value)
|
|
89
|
+
)}
|
|
90
|
+
title={value < 0 ? "No data" : `${value}%`}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{labels.length > 0 && (
|
|
96
|
+
<div className="flex justify-between text-xs text-foreground-muted">
|
|
97
|
+
{labels.map((label, i) => (
|
|
98
|
+
<span key={i}>{label}</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { Uptime };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import JSON5 from "json5";
|
|
5
|
+
|
|
6
|
+
export interface CastleConfig {
|
|
7
|
+
openclaw: {
|
|
8
|
+
gateway_port: number;
|
|
9
|
+
gateway_token?: string;
|
|
10
|
+
primary_agent?: string;
|
|
11
|
+
};
|
|
12
|
+
server: {
|
|
13
|
+
port: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG: CastleConfig = {
|
|
18
|
+
openclaw: {
|
|
19
|
+
gateway_port: 18789,
|
|
20
|
+
},
|
|
21
|
+
server: {
|
|
22
|
+
port: 3333,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function getCastleDir(): string {
|
|
27
|
+
return join(homedir(), ".castle");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getConfigPath(): string {
|
|
31
|
+
return join(getCastleDir(), "castle.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ensureCastleDir(): void {
|
|
35
|
+
const dir = getCastleDir();
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
const dataDir = join(dir, "data");
|
|
40
|
+
if (!existsSync(dataDir)) {
|
|
41
|
+
mkdirSync(dataDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function configExists(): boolean {
|
|
46
|
+
return existsSync(getConfigPath());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readConfig(): CastleConfig {
|
|
50
|
+
const configPath = getConfigPath();
|
|
51
|
+
if (!existsSync(configPath)) {
|
|
52
|
+
return { ...DEFAULT_CONFIG };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
56
|
+
const parsed = JSON5.parse(raw);
|
|
57
|
+
return {
|
|
58
|
+
...DEFAULT_CONFIG,
|
|
59
|
+
...parsed,
|
|
60
|
+
openclaw: { ...DEFAULT_CONFIG.openclaw, ...parsed.openclaw },
|
|
61
|
+
server: { ...DEFAULT_CONFIG.server, ...parsed.server },
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...DEFAULT_CONFIG };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writeConfig(config: CastleConfig): void {
|
|
69
|
+
ensureCastleDir();
|
|
70
|
+
const configPath = getConfigPath();
|
|
71
|
+
const content = JSON5.stringify(config, null, 2);
|
|
72
|
+
writeFileSync(configPath, content, "utf-8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load a .env file and set values into process.env (does not override existing)
|
|
77
|
+
*/
|
|
78
|
+
function loadEnvFile(envPath: string): void {
|
|
79
|
+
if (!existsSync(envPath)) return;
|
|
80
|
+
try {
|
|
81
|
+
const raw = readFileSync(envPath, "utf-8");
|
|
82
|
+
for (const line of raw.split("\n")) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
85
|
+
const eqIdx = trimmed.indexOf("=");
|
|
86
|
+
if (eqIdx === -1) continue;
|
|
87
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
88
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
89
|
+
// Strip surrounding quotes
|
|
90
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
91
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
92
|
+
value = value.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
if (!process.env[key]) {
|
|
95
|
+
process.env[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore errors loading .env
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve ${ENV_VAR} references in a string value.
|
|
105
|
+
* Users often have "token": "${OPENCLAW_GATEWAY_TOKEN}" in their config.
|
|
106
|
+
*/
|
|
107
|
+
function resolveEnvVar(value: string): string | null {
|
|
108
|
+
if (value.startsWith("${") && value.endsWith("}")) {
|
|
109
|
+
const envVar = value.slice(2, -1);
|
|
110
|
+
return process.env[envVar] || null;
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get the OpenClaw directory path
|
|
117
|
+
*/
|
|
118
|
+
export function getOpenClawDir(): string {
|
|
119
|
+
return join(homedir(), ".openclaw");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Try to read the OpenClaw gateway token from ~/.openclaw/openclaw.json
|
|
124
|
+
* Handles ${ENV_VAR} references and loads ~/.openclaw/.env
|
|
125
|
+
*/
|
|
126
|
+
export function readOpenClawToken(): string | null {
|
|
127
|
+
// Load ~/.openclaw/.env first so env var references can resolve
|
|
128
|
+
loadEnvFile(join(getOpenClawDir(), ".env"));
|
|
129
|
+
|
|
130
|
+
const paths = [
|
|
131
|
+
join(homedir(), ".openclaw", "openclaw.json"),
|
|
132
|
+
join(homedir(), ".openclaw", "openclaw.json5"),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
for (const p of paths) {
|
|
136
|
+
if (!existsSync(p)) continue;
|
|
137
|
+
try {
|
|
138
|
+
const raw = readFileSync(p, "utf-8");
|
|
139
|
+
const parsed = JSON5.parse(raw);
|
|
140
|
+
const token = parsed?.gateway?.auth?.token;
|
|
141
|
+
if (token && typeof token === "string") {
|
|
142
|
+
return resolveEnvVar(token);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Continue to next path
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback: check env vars directly
|
|
150
|
+
return process.env.OPENCLAW_GATEWAY_TOKEN || null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Try to read the OpenClaw gateway port from ~/.openclaw/openclaw.json
|
|
155
|
+
*/
|
|
156
|
+
export function readOpenClawPort(): number | null {
|
|
157
|
+
const paths = [
|
|
158
|
+
join(homedir(), ".openclaw", "openclaw.json"),
|
|
159
|
+
join(homedir(), ".openclaw", "openclaw.json5"),
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const p of paths) {
|
|
163
|
+
if (!existsSync(p)) continue;
|
|
164
|
+
try {
|
|
165
|
+
const raw = readFileSync(p, "utf-8");
|
|
166
|
+
const parsed = JSON5.parse(raw);
|
|
167
|
+
const port = parsed?.gateway?.port;
|
|
168
|
+
if (typeof port === "number" && port > 0 && port <= 65535) {
|
|
169
|
+
return port;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Continue
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the Gateway WebSocket URL.
|
|
180
|
+
* Supports OPENCLAW_GATEWAY_URL env var, falls back to config port.
|
|
181
|
+
*/
|
|
182
|
+
export function getGatewayUrl(): string {
|
|
183
|
+
if (process.env.OPENCLAW_GATEWAY_URL) {
|
|
184
|
+
return process.env.OPENCLAW_GATEWAY_URL;
|
|
185
|
+
}
|
|
186
|
+
const config = readConfig();
|
|
187
|
+
return `ws://127.0.0.1:${config.openclaw.gateway_port}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check if OpenClaw is installed by looking for the config directory
|
|
192
|
+
*/
|
|
193
|
+
export function isOpenClawInstalled(): boolean {
|
|
194
|
+
return existsSync(join(homedir(), ".openclaw"));
|
|
195
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { getGatewayUrl, readOpenClawToken, readConfig, configExists } from "./config";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
interface RequestFrame {
|
|
11
|
+
type: "req";
|
|
12
|
+
id: string;
|
|
13
|
+
method: string;
|
|
14
|
+
params?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ResponseFrame {
|
|
18
|
+
type: "res";
|
|
19
|
+
id: string;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
payload?: unknown;
|
|
22
|
+
error?: {
|
|
23
|
+
code: string;
|
|
24
|
+
message: string;
|
|
25
|
+
details?: unknown;
|
|
26
|
+
retryable?: boolean;
|
|
27
|
+
retryAfterMs?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface EventFrame {
|
|
32
|
+
type: "event";
|
|
33
|
+
event: string;
|
|
34
|
+
payload?: unknown;
|
|
35
|
+
seq?: number;
|
|
36
|
+
stateVersion?: { version: number };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type GatewayFrame = ResponseFrame | EventFrame | { type: string; [key: string]: unknown };
|
|
40
|
+
|
|
41
|
+
interface PendingRequest {
|
|
42
|
+
resolve: (payload: unknown) => void;
|
|
43
|
+
reject: (error: Error) => void;
|
|
44
|
+
timer: ReturnType<typeof setTimeout>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ConnectionState = "disconnected" | "connecting" | "connected" | "error";
|
|
48
|
+
|
|
49
|
+
export interface GatewayEvent {
|
|
50
|
+
event: string;
|
|
51
|
+
payload?: unknown;
|
|
52
|
+
seq?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Singleton Gateway Connection
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
class GatewayConnection extends EventEmitter {
|
|
60
|
+
private ws: WebSocket | null = null;
|
|
61
|
+
private pending = new Map<string, PendingRequest>();
|
|
62
|
+
private _state: ConnectionState = "disconnected";
|
|
63
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
private reconnectAttempts = 0;
|
|
65
|
+
private maxReconnectDelay = 30000; // 30s max
|
|
66
|
+
private baseReconnectDelay = 1000; // 1s base
|
|
67
|
+
private requestTimeout = 15000; // 15s per request
|
|
68
|
+
private connectTimeout = 10000; // 10s for connect handshake
|
|
69
|
+
private _serverInfo: { version?: string; connId?: string } = {};
|
|
70
|
+
private _features: { methods?: string[]; events?: string[] } = {};
|
|
71
|
+
private shouldReconnect = true;
|
|
72
|
+
|
|
73
|
+
get state(): ConnectionState {
|
|
74
|
+
return this._state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get serverInfo() {
|
|
78
|
+
return this._serverInfo;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get isConnected(): boolean {
|
|
82
|
+
return this._state === "connected";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get isConfigured(): boolean {
|
|
86
|
+
// Check if we can find a token from any source
|
|
87
|
+
return !!this.resolveToken();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --------------------------------------------------------------------------
|
|
91
|
+
// Connection lifecycle
|
|
92
|
+
// --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
start(): void {
|
|
95
|
+
if (this._state === "connecting" || this._state === "connected") return;
|
|
96
|
+
this.shouldReconnect = true;
|
|
97
|
+
this.connect();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.shouldReconnect = false;
|
|
102
|
+
this.clearReconnectTimer();
|
|
103
|
+
this.cleanup();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private connect(): void {
|
|
107
|
+
const token = this.resolveToken();
|
|
108
|
+
if (!token) {
|
|
109
|
+
this._state = "error";
|
|
110
|
+
this.emit("stateChange", this._state);
|
|
111
|
+
console.error("[Gateway] No token available. Run 'castle setup' to configure.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = getGatewayUrl();
|
|
116
|
+
this._state = "connecting";
|
|
117
|
+
this.emit("stateChange", this._state);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
this.ws = new WebSocket(url);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error("[Gateway] Failed to create WebSocket:", err);
|
|
123
|
+
this._state = "error";
|
|
124
|
+
this.emit("stateChange", this._state);
|
|
125
|
+
this.scheduleReconnect();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const connectTimer = setTimeout(() => {
|
|
130
|
+
console.error("[Gateway] Connect timeout");
|
|
131
|
+
this.cleanup();
|
|
132
|
+
this.scheduleReconnect();
|
|
133
|
+
}, this.connectTimeout);
|
|
134
|
+
|
|
135
|
+
this.ws.on("open", () => {
|
|
136
|
+
// Build connect handshake
|
|
137
|
+
const connectId = randomUUID();
|
|
138
|
+
const connectFrame: RequestFrame = {
|
|
139
|
+
type: "req",
|
|
140
|
+
id: connectId,
|
|
141
|
+
method: "connect",
|
|
142
|
+
params: {
|
|
143
|
+
minProtocol: 3,
|
|
144
|
+
maxProtocol: 3,
|
|
145
|
+
client: {
|
|
146
|
+
id: "gateway-client",
|
|
147
|
+
displayName: "Castle",
|
|
148
|
+
version: "0.0.1",
|
|
149
|
+
platform: process.platform,
|
|
150
|
+
mode: "backend",
|
|
151
|
+
},
|
|
152
|
+
auth: { token },
|
|
153
|
+
role: "operator",
|
|
154
|
+
scopes: ["operator.admin"],
|
|
155
|
+
caps: [],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Handle handshake messages (may include connect.challenge events)
|
|
160
|
+
const onHandshakeMessage = (data: WebSocket.RawData) => {
|
|
161
|
+
try {
|
|
162
|
+
const msg = JSON.parse(data.toString());
|
|
163
|
+
|
|
164
|
+
// Handle connect.challenge event -- re-send connect with nonce
|
|
165
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
166
|
+
// For now we don't support device-signed nonce challenges
|
|
167
|
+
// Just proceed with the connect
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Standard response frame to our connect request
|
|
172
|
+
if (msg.type === "res" && msg.id === connectId) {
|
|
173
|
+
clearTimeout(connectTimer);
|
|
174
|
+
|
|
175
|
+
if (msg.ok) {
|
|
176
|
+
// hello-ok is embedded in the payload
|
|
177
|
+
const helloOk = msg.payload || {};
|
|
178
|
+
this._state = "connected";
|
|
179
|
+
this._serverInfo = helloOk.server || {};
|
|
180
|
+
this._features = helloOk.features || {};
|
|
181
|
+
this.reconnectAttempts = 0;
|
|
182
|
+
this.emit("stateChange", this._state);
|
|
183
|
+
this.emit("connected", helloOk);
|
|
184
|
+
console.log(`[Gateway] Connected to OpenClaw v${helloOk.server?.version || "unknown"}`);
|
|
185
|
+
// Switch to normal message handler
|
|
186
|
+
this.ws?.off("message", onHandshakeMessage);
|
|
187
|
+
this.ws?.on("message", this.onMessage.bind(this));
|
|
188
|
+
} else {
|
|
189
|
+
const errMsg = msg.error?.message || "Connect rejected";
|
|
190
|
+
console.error(`[Gateway] Connect failed: ${errMsg}`);
|
|
191
|
+
this.ws?.off("message", onHandshakeMessage);
|
|
192
|
+
this.cleanup();
|
|
193
|
+
// Don't reconnect on auth errors
|
|
194
|
+
if (msg.error?.code === "auth_failed") {
|
|
195
|
+
this._state = "error";
|
|
196
|
+
this.emit("stateChange", this._state);
|
|
197
|
+
this.emit("authError", msg.error);
|
|
198
|
+
} else {
|
|
199
|
+
this.scheduleReconnect();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Forward any events that arrive during handshake
|
|
206
|
+
if (msg.type === "event") {
|
|
207
|
+
this.emit("gatewayEvent", {
|
|
208
|
+
event: msg.event,
|
|
209
|
+
payload: msg.payload,
|
|
210
|
+
seq: msg.seq,
|
|
211
|
+
} as GatewayEvent);
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("[Gateway] Failed to parse handshake message:", err);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
this.ws!.on("message", onHandshakeMessage);
|
|
219
|
+
this.ws!.send(JSON.stringify(connectFrame));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.ws.on("error", (err) => {
|
|
223
|
+
clearTimeout(connectTimer);
|
|
224
|
+
console.error("[Gateway] WebSocket error:", err.message);
|
|
225
|
+
this.cleanup();
|
|
226
|
+
this.scheduleReconnect();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
this.ws.on("close", (code, reason) => {
|
|
230
|
+
clearTimeout(connectTimer);
|
|
231
|
+
const wasConnected = this._state === "connected";
|
|
232
|
+
this.cleanup();
|
|
233
|
+
if (wasConnected) {
|
|
234
|
+
console.log(`[Gateway] Disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
|
|
235
|
+
}
|
|
236
|
+
this.scheduleReconnect();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private onMessage(data: WebSocket.RawData): void {
|
|
241
|
+
let msg: GatewayFrame;
|
|
242
|
+
try {
|
|
243
|
+
msg = JSON.parse(data.toString());
|
|
244
|
+
} catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (msg.type === "res") {
|
|
249
|
+
const res = msg as ResponseFrame;
|
|
250
|
+
const pending = this.pending.get(res.id);
|
|
251
|
+
if (pending) {
|
|
252
|
+
clearTimeout(pending.timer);
|
|
253
|
+
this.pending.delete(res.id);
|
|
254
|
+
if (res.ok) {
|
|
255
|
+
pending.resolve(res.payload);
|
|
256
|
+
} else {
|
|
257
|
+
pending.reject(
|
|
258
|
+
new Error(res.error?.message || "Request failed")
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} else if (msg.type === "event") {
|
|
263
|
+
const evt = msg as EventFrame;
|
|
264
|
+
this.emit("gatewayEvent", {
|
|
265
|
+
event: evt.event,
|
|
266
|
+
payload: evt.payload,
|
|
267
|
+
seq: evt.seq,
|
|
268
|
+
} as GatewayEvent);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------------------
|
|
273
|
+
// RPC
|
|
274
|
+
// --------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
async request<T = unknown>(method: string, params: unknown = {}): Promise<T> {
|
|
277
|
+
if (!this.ws || this._state !== "connected") {
|
|
278
|
+
throw new Error("Gateway not connected");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const id = randomUUID();
|
|
282
|
+
const frame: RequestFrame = { type: "req", id, method, params };
|
|
283
|
+
|
|
284
|
+
return new Promise<T>((resolve, reject) => {
|
|
285
|
+
const timer = setTimeout(() => {
|
|
286
|
+
this.pending.delete(id);
|
|
287
|
+
reject(new Error(`Request timeout: ${method}`));
|
|
288
|
+
}, this.requestTimeout);
|
|
289
|
+
|
|
290
|
+
this.pending.set(id, {
|
|
291
|
+
resolve: resolve as (payload: unknown) => void,
|
|
292
|
+
reject,
|
|
293
|
+
timer,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
this.ws!.send(JSON.stringify(frame), (err) => {
|
|
297
|
+
if (err) {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
this.pending.delete(id);
|
|
300
|
+
reject(new Error(`Send failed: ${err.message}`));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --------------------------------------------------------------------------
|
|
307
|
+
// Helpers
|
|
308
|
+
// --------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
private resolveToken(): string | null {
|
|
311
|
+
// 1. Castle config token
|
|
312
|
+
if (configExists()) {
|
|
313
|
+
const config = readConfig();
|
|
314
|
+
if (config.openclaw.gateway_token) {
|
|
315
|
+
return config.openclaw.gateway_token;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 2. Auto-detect from OpenClaw config
|
|
319
|
+
return readOpenClawToken();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private cleanup(): void {
|
|
323
|
+
if (this.ws) {
|
|
324
|
+
this.ws.removeAllListeners();
|
|
325
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
326
|
+
try { this.ws.close(); } catch { /* ignore */ }
|
|
327
|
+
}
|
|
328
|
+
this.ws = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Reject all pending requests
|
|
332
|
+
for (const [id, pending] of this.pending) {
|
|
333
|
+
clearTimeout(pending.timer);
|
|
334
|
+
pending.reject(new Error("Connection closed"));
|
|
335
|
+
this.pending.delete(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (this._state !== "error") {
|
|
339
|
+
this._state = "disconnected";
|
|
340
|
+
this.emit("stateChange", this._state);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private scheduleReconnect(): void {
|
|
345
|
+
if (!this.shouldReconnect) return;
|
|
346
|
+
this.clearReconnectTimer();
|
|
347
|
+
|
|
348
|
+
const delay = Math.min(
|
|
349
|
+
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts),
|
|
350
|
+
this.maxReconnectDelay
|
|
351
|
+
);
|
|
352
|
+
this.reconnectAttempts++;
|
|
353
|
+
|
|
354
|
+
console.log(`[Gateway] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
355
|
+
this.reconnectTimer = setTimeout(() => {
|
|
356
|
+
this.connect();
|
|
357
|
+
}, delay);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private clearReconnectTimer(): void {
|
|
361
|
+
if (this.reconnectTimer) {
|
|
362
|
+
clearTimeout(this.reconnectTimer);
|
|
363
|
+
this.reconnectTimer = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// Singleton export
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
let _gateway: GatewayConnection | null = null;
|
|
373
|
+
|
|
374
|
+
export function getGateway(): GatewayConnection {
|
|
375
|
+
if (!_gateway) {
|
|
376
|
+
_gateway = new GatewayConnection();
|
|
377
|
+
}
|
|
378
|
+
return _gateway;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Ensure the gateway is started and return the instance.
|
|
383
|
+
* Safe to call multiple times -- only connects once.
|
|
384
|
+
*/
|
|
385
|
+
export function ensureGateway(): GatewayConnection {
|
|
386
|
+
const gw = getGateway();
|
|
387
|
+
if (gw.state === "disconnected") {
|
|
388
|
+
gw.start();
|
|
389
|
+
}
|
|
390
|
+
return gw;
|
|
391
|
+
}
|