@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.
Files changed (63) hide show
  1. package/README.md +38 -1
  2. package/bin/castle.js +94 -0
  3. package/install.sh +722 -0
  4. package/next.config.ts +7 -0
  5. package/package.json +54 -5
  6. package/postcss.config.mjs +7 -0
  7. package/src/app/api/avatars/[id]/route.ts +75 -0
  8. package/src/app/api/openclaw/agents/route.ts +107 -0
  9. package/src/app/api/openclaw/config/route.ts +94 -0
  10. package/src/app/api/openclaw/events/route.ts +96 -0
  11. package/src/app/api/openclaw/logs/route.ts +59 -0
  12. package/src/app/api/openclaw/ping/route.ts +68 -0
  13. package/src/app/api/openclaw/restart/route.ts +65 -0
  14. package/src/app/api/openclaw/sessions/route.ts +62 -0
  15. package/src/app/globals.css +286 -0
  16. package/src/app/icon.png +0 -0
  17. package/src/app/layout.tsx +42 -0
  18. package/src/app/page.tsx +269 -0
  19. package/src/app/ui-kit/page.tsx +684 -0
  20. package/src/cli/onboarding.ts +576 -0
  21. package/src/components/dashboard/agent-status.tsx +107 -0
  22. package/src/components/dashboard/glass-card.tsx +28 -0
  23. package/src/components/dashboard/goal-widget.tsx +174 -0
  24. package/src/components/dashboard/greeting-widget.tsx +78 -0
  25. package/src/components/dashboard/index.ts +7 -0
  26. package/src/components/dashboard/stat-widget.tsx +61 -0
  27. package/src/components/dashboard/stock-widget.tsx +164 -0
  28. package/src/components/dashboard/weather-widget.tsx +68 -0
  29. package/src/components/icons/castle-icon.tsx +21 -0
  30. package/src/components/kanban/index.ts +3 -0
  31. package/src/components/kanban/kanban-board.tsx +391 -0
  32. package/src/components/kanban/kanban-card.tsx +137 -0
  33. package/src/components/kanban/kanban-column.tsx +98 -0
  34. package/src/components/layout/index.ts +4 -0
  35. package/src/components/layout/page-header.tsx +20 -0
  36. package/src/components/layout/sidebar.tsx +128 -0
  37. package/src/components/layout/theme-toggle.tsx +59 -0
  38. package/src/components/layout/user-menu.tsx +72 -0
  39. package/src/components/ui/alert.tsx +72 -0
  40. package/src/components/ui/avatar.tsx +87 -0
  41. package/src/components/ui/badge.tsx +39 -0
  42. package/src/components/ui/button.tsx +43 -0
  43. package/src/components/ui/card.tsx +107 -0
  44. package/src/components/ui/checkbox.tsx +56 -0
  45. package/src/components/ui/clock.tsx +171 -0
  46. package/src/components/ui/dialog.tsx +105 -0
  47. package/src/components/ui/index.ts +34 -0
  48. package/src/components/ui/input.tsx +112 -0
  49. package/src/components/ui/option-card.tsx +151 -0
  50. package/src/components/ui/progress.tsx +103 -0
  51. package/src/components/ui/radio.tsx +109 -0
  52. package/src/components/ui/select.tsx +46 -0
  53. package/src/components/ui/slider.tsx +62 -0
  54. package/src/components/ui/tabs.tsx +132 -0
  55. package/src/components/ui/toggle-group.tsx +85 -0
  56. package/src/components/ui/toggle.tsx +78 -0
  57. package/src/components/ui/tooltip.tsx +145 -0
  58. package/src/components/ui/uptime.tsx +106 -0
  59. package/src/lib/config.ts +195 -0
  60. package/src/lib/gateway-connection.ts +391 -0
  61. package/src/lib/hooks/use-openclaw.ts +163 -0
  62. package/src/lib/utils.ts +6 -0
  63. 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
+ }