openclaw-bridge 0.2.2 → 0.3.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/src/config.ts CHANGED
@@ -55,6 +55,15 @@ export function parseConfig(raw: unknown): BridgeConfig {
55
55
  typeof obj.offlineThresholdMs === "number"
56
56
  ? obj.offlineThresholdMs
57
57
  : DEFAULTS.offlineThresholdMs,
58
+ description: typeof obj.description === "string" ? obj.description : undefined,
59
+ supportsVision: typeof obj.supportsVision === "boolean" ? obj.supportsVision : undefined,
60
+ localManager: obj.localManager
61
+ ? {
62
+ enabled: !!(obj.localManager as Record<string, unknown>).enabled,
63
+ hubUrl: (obj.localManager as Record<string, unknown>).hubUrl as string,
64
+ managerPass: (obj.localManager as Record<string, unknown>).managerPass as string,
65
+ }
66
+ : undefined,
58
67
  };
59
68
  }
60
69
 
package/src/heartbeat.ts CHANGED
@@ -73,6 +73,8 @@ export class BridgeHeartbeat {
73
73
  await this.detectConfigChanges();
74
74
 
75
75
  this.entry.lastHeartbeat = new Date().toISOString();
76
+ this.entry.memMB = Math.round(process.memoryUsage().rss / 1024 / 1024);
77
+ this.entry.supportsVision = this.detectVisionSupport();
76
78
 
77
79
  const currentHash = this.computeEntryHash();
78
80
  if (currentHash !== this.lastConfigHash) {
@@ -87,6 +89,30 @@ export class BridgeHeartbeat {
87
89
  await this.fileOps.processPendingCommands();
88
90
  }
89
91
 
92
+ private detectVisionSupport(): boolean {
93
+ if (this.config.supportsVision !== undefined) {
94
+ return this.config.supportsVision;
95
+ }
96
+ if (!this.configPath) return false;
97
+ try {
98
+ const raw = readFileSync(this.configPath, "utf-8");
99
+ const config = JSON.parse(raw) as {
100
+ models?: {
101
+ default?: string;
102
+ list?: Array<{ id?: string; name?: string }>;
103
+ };
104
+ };
105
+ const defaultModel = config.models?.default ?? "";
106
+ const visionPatterns = [
107
+ "gpt-4o", "gpt-4-vision", "claude-3", "claude-sonnet", "claude-opus",
108
+ "gemini", "gemini-pro", "gemini-2", "pixtral", "llava",
109
+ ];
110
+ return visionPatterns.some((p) => defaultModel.toLowerCase().includes(p));
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
90
116
  private async detectConfigChanges(): Promise<void> {
91
117
  if (!this.configPath) return;
92
118
 
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { assertPermission } from "./permissions.js";
9
9
  import { discoverAll, whois } from "./discovery.js";
10
10
  import { MessageRelayClient } from "./message-relay.js";
11
11
  import * as proxySession from "./session.js";
12
+ import { LocalManager } from "./manager/local-manager.js";
12
13
  import type { OpenClawPluginApi, RegistryEntry } from "./types.js";
13
14
 
14
15
  function resolveWorkspacePath(agentId: string): string {
@@ -136,10 +137,21 @@ const bridgePlugin = {
136
137
  registeredAt: new Date().toISOString(),
137
138
  lastHeartbeat: new Date().toISOString(),
138
139
  status: "online",
140
+ description: config.description,
141
+ supportsVision: config.supportsVision,
139
142
  };
140
143
 
141
144
  const heartbeat = new BridgeHeartbeat(config, registry, fileOps, entry, api.logger);
142
145
 
146
+ let localManager: LocalManager | null = null;
147
+ if (config.localManager?.enabled) {
148
+ localManager = new LocalManager(
149
+ config.localManager,
150
+ config.fileRelay?.apiKey ?? "",
151
+ api.logger,
152
+ );
153
+ }
154
+
143
155
  // Cache for context injection (refreshed every heartbeat)
144
156
  let cachedAgentList = "";
145
157
  let lastDiscoverTime = 0;
@@ -223,6 +235,9 @@ ${nameMapping}
223
235
  id: "openclaw-bridge",
224
236
  async start() {
225
237
  await heartbeat.start();
238
+ if (localManager) {
239
+ await localManager.start();
240
+ }
226
241
  await refreshAgentContext();
227
242
 
228
243
  // Initialize Message Relay if configured
@@ -244,6 +259,23 @@ ${nameMapping}
244
259
  gatewayToken = raw.gateway?.auth?.token || '';
245
260
  } catch { /* no token */ }
246
261
 
262
+ // Check if payload is multimodal JSON (from Hub chat with image)
263
+ let messages: any[];
264
+ try {
265
+ const parsed = JSON.parse(payload);
266
+ if (parsed.text && parsed.image) {
267
+ // Multimodal: text + image
268
+ messages = [{ role: 'user', content: [
269
+ { type: 'text', text: parsed.text },
270
+ { type: 'image_url', image_url: { url: parsed.image } },
271
+ ]}];
272
+ } else {
273
+ messages = [{ role: 'user', content: payload }];
274
+ }
275
+ } catch {
276
+ messages = [{ role: 'user', content: payload }];
277
+ }
278
+
247
279
  const url = `http://127.0.0.1:${entry.port}/v1/chat/completions`;
248
280
  const res = await fetch(url, {
249
281
  method: 'POST',
@@ -253,7 +285,7 @@ ${nameMapping}
253
285
  },
254
286
  body: JSON.stringify({
255
287
  model: 'openclaw/default',
256
- messages: [{ role: 'user', content: payload }],
288
+ messages,
257
289
  }),
258
290
  signal: AbortSignal.timeout(55_000),
259
291
  });
@@ -349,6 +381,9 @@ ${nameMapping}
349
381
  api.logger.info(`openclaw-bridge: initialized (${config.agentId}, role=${config.role})`);
350
382
  },
351
383
  async stop() {
384
+ if (localManager) {
385
+ await localManager.stop();
386
+ }
352
387
  if (relayClient) {
353
388
  await relayClient.disconnect();
354
389
  }
@@ -0,0 +1,114 @@
1
+ import WebSocket from "ws";
2
+ import { hostname } from "node:os";
3
+ import type { PluginLogger } from "../types.js";
4
+
5
+ export class ManagerHubClient {
6
+ private hubUrl: string;
7
+ private apiKey: string;
8
+ private managerPass: string;
9
+ private machineId: string;
10
+ private ws: WebSocket | null = null;
11
+ private _connected = false;
12
+ private reconnectDelay = 1000;
13
+ private logger: PluginLogger;
14
+ onCommand: ((msg: any) => void) | null = null;
15
+
16
+ constructor(
17
+ hubUrl: string,
18
+ apiKey: string,
19
+ managerPass: string,
20
+ logger: PluginLogger,
21
+ ) {
22
+ this.hubUrl = hubUrl;
23
+ this.apiKey = apiKey;
24
+ this.managerPass = managerPass;
25
+ this.machineId = hostname();
26
+ this.logger = logger;
27
+ }
28
+
29
+ get connected(): boolean {
30
+ return this._connected;
31
+ }
32
+
33
+ async connect(): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ const wsUrl = this.hubUrl.replace(/^http/, "ws") + "/ws/manager";
36
+ this.ws = new WebSocket(wsUrl);
37
+
38
+ this.ws.on("open", () => {
39
+ this.ws!.send(
40
+ JSON.stringify({
41
+ type: "auth",
42
+ role: "manager",
43
+ machineId: this.machineId,
44
+ apiKey: this.apiKey,
45
+ managerPass: this.managerPass,
46
+ }),
47
+ );
48
+ });
49
+
50
+ this.ws.on("message", (data) => {
51
+ const msg = JSON.parse(data.toString());
52
+ if (msg.type === "auth_ok") {
53
+ this._connected = true;
54
+ this.reconnectDelay = 1000;
55
+ this.logger.info(
56
+ `[local-manager] Hub connected as ${this.machineId}`,
57
+ );
58
+ resolve();
59
+ return;
60
+ }
61
+ if (msg.type === "auth_error") {
62
+ reject(new Error(msg.reason));
63
+ return;
64
+ }
65
+ if (msg.type === "manager_command" && this.onCommand) {
66
+ this.onCommand(msg);
67
+ }
68
+ });
69
+
70
+ this.ws.on("close", () => {
71
+ this._connected = false;
72
+ this.logger.warn(
73
+ `[local-manager] Hub disconnected, reconnecting in ${this.reconnectDelay}ms...`,
74
+ );
75
+ setTimeout(() => {
76
+ this.connect().catch(() => {
77
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
78
+ });
79
+ }, this.reconnectDelay);
80
+ });
81
+
82
+ this.ws.on("error", (err) => {
83
+ this.logger.error(`[local-manager] Hub error: ${err.message}`);
84
+ });
85
+ });
86
+ }
87
+
88
+ sendStatus(agents: any[], logs?: Record<string, string>): void {
89
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
90
+ this.ws.send(
91
+ JSON.stringify({
92
+ type: "manager_status",
93
+ machineId: this.machineId,
94
+ agents,
95
+ logs,
96
+ }),
97
+ );
98
+ }
99
+ }
100
+
101
+ sendResult(action: string, target: string, success: boolean): void {
102
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
103
+ this.ws.send(JSON.stringify({ type: "manager_result", action, target, success }));
104
+ }
105
+ }
106
+
107
+ disconnect(): void {
108
+ if (this.ws) {
109
+ this.ws.close();
110
+ this.ws = null;
111
+ this._connected = false;
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import type { PluginLogger, LocalManagerConfig } from "../types.js";
5
+ import { ManagerHubClient } from "./hub-client.js";
6
+ import {
7
+ listProcesses,
8
+ getProcessLogs,
9
+ restartProcess,
10
+ stopProcess,
11
+ startProcess,
12
+ stopAll,
13
+ } from "./pm2-bridge.js";
14
+
15
+ const LOCK_FILE = join(tmpdir(), "openclaw-local-manager.lock");
16
+
17
+ function acquireLock(): boolean {
18
+ if (existsSync(LOCK_FILE)) {
19
+ try {
20
+ const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
21
+ process.kill(pid, 0);
22
+ return false;
23
+ } catch {
24
+ // Process is dead, steal the lock
25
+ }
26
+ }
27
+ writeFileSync(LOCK_FILE, String(process.pid));
28
+ return true;
29
+ }
30
+
31
+ function releaseLock(): void {
32
+ try { unlinkSync(LOCK_FILE); } catch {}
33
+ }
34
+
35
+ export class LocalManager {
36
+ private config: LocalManagerConfig;
37
+ private apiKey: string;
38
+ private logger: PluginLogger;
39
+ private hubClient: ManagerHubClient | null = null;
40
+ private statusTimer: ReturnType<typeof setInterval> | null = null;
41
+ private hasLock = false;
42
+
43
+ constructor(config: LocalManagerConfig, apiKey: string, logger: PluginLogger) {
44
+ this.config = config;
45
+ this.apiKey = apiKey;
46
+ this.logger = logger;
47
+ }
48
+
49
+ async start(): Promise<void> {
50
+ if (!this.config.enabled) return;
51
+
52
+ this.hasLock = acquireLock();
53
+ if (!this.hasLock) {
54
+ this.logger.info("[local-manager] Another instance is already managing this machine, skipping");
55
+ return;
56
+ }
57
+
58
+ this.hubClient = new ManagerHubClient(
59
+ this.config.hubUrl, this.apiKey, this.config.managerPass, this.logger,
60
+ );
61
+
62
+ this.hubClient.onCommand = async (msg: any) => {
63
+ const processes = await listProcesses();
64
+ let target = msg.target;
65
+
66
+ if (msg.action !== "restart-all" && msg.action !== "stop-all") {
67
+ const match = processes.find((p) => p.agentId === msg.target || p.name === msg.target);
68
+ if (match) {
69
+ target = match.name;
70
+ } else {
71
+ this.logger.error(`[local-manager] No PM2 process found for: ${msg.target}`);
72
+ this.hubClient!.sendResult(msg.action, msg.target, false);
73
+ return;
74
+ }
75
+ }
76
+
77
+ this.logger.info(`[local-manager] ${msg.action} ${target}`);
78
+ try {
79
+ if (msg.action === "restart") await restartProcess(target);
80
+ else if (msg.action === "stop") await stopProcess(target);
81
+ else if (msg.action === "start") await startProcess(target);
82
+ else if (msg.action === "restart-all") await stopAll();
83
+ else if (msg.action === "stop-all") await stopAll();
84
+ this.hubClient!.sendResult(msg.action, msg.target, true);
85
+ } catch (err: any) {
86
+ this.logger.error(`[local-manager] Failed: ${err.message}`);
87
+ this.hubClient!.sendResult(msg.action, msg.target, false);
88
+ }
89
+ };
90
+
91
+ try {
92
+ await this.hubClient.connect();
93
+ } catch (err: any) {
94
+ this.logger.warn(`[local-manager] Hub connect failed: ${err.message}. Will retry.`);
95
+ }
96
+
97
+ this.statusTimer = setInterval(async () => {
98
+ try {
99
+ const processes = await listProcesses();
100
+ const logs: Record<string, string> = {};
101
+ for (const proc of processes) {
102
+ logs[proc.name] = await getProcessLogs(proc.name);
103
+ }
104
+ if (this.hubClient?.connected) {
105
+ this.hubClient.sendStatus(processes, logs);
106
+ }
107
+ } catch (err: any) {
108
+ this.logger.warn(`[local-manager] Status tick failed: ${err.message}`);
109
+ }
110
+ }, 30_000);
111
+
112
+ this.logger.info("[local-manager] Started (reporting every 30s)");
113
+ }
114
+
115
+ async stop(): Promise<void> {
116
+ if (this.statusTimer) { clearInterval(this.statusTimer); this.statusTimer = null; }
117
+ if (this.hubClient) { this.hubClient.disconnect(); this.hubClient = null; }
118
+ if (this.hasLock) releaseLock();
119
+ this.logger.info("[local-manager] Stopped");
120
+ }
121
+ }
@@ -0,0 +1,125 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { platform } from "node:os";
4
+
5
+ const _exec = promisify(exec);
6
+ const execAsync = (cmd: string, opts?: Record<string, unknown>) =>
7
+ _exec(cmd, { windowsHide: true, encoding: "utf-8", ...opts } as any);
8
+ const IS_WIN = platform() === "win32";
9
+
10
+ export interface PM2Process {
11
+ name: string;
12
+ agentId: string;
13
+ pid: number;
14
+ status: string;
15
+ memory: number;
16
+ cpu: number;
17
+ restarts: number;
18
+ uptime: number;
19
+ }
20
+
21
+ async function killPort(port: number): Promise<void> {
22
+ if (!port) return;
23
+ try {
24
+ if (IS_WIN) {
25
+ const { stdout } = await execAsync(
26
+ `netstat -ano | findstr :${port} | findstr LISTENING`,
27
+ );
28
+ const pids = new Set<string>();
29
+ stdout.split("\n").forEach((line: string) => {
30
+ const pid = line.trim().split(/\s+/).pop();
31
+ if (pid && /^\d+$/.test(pid) && pid !== "0") pids.add(pid);
32
+ });
33
+ for (const pid of pids) {
34
+ await execAsync(`taskkill /F /PID ${pid}`).catch(() => {});
35
+ }
36
+ } else {
37
+ await execAsync(`lsof -ti:${port} | xargs kill -9`).catch(() => {});
38
+ }
39
+ } catch {
40
+ // No process on port
41
+ }
42
+ }
43
+
44
+ async function getProcessPort(name: string): Promise<number | null> {
45
+ try {
46
+ const { stdout } = await execAsync("pm2 jlist");
47
+ const apps = JSON.parse(stdout);
48
+ const app = apps.find((a: any) => a.name === name);
49
+ if (app?.pm2_env?.env?.OPENCLAW_GATEWAY_PORT) {
50
+ return parseInt(app.pm2_env.env.OPENCLAW_GATEWAY_PORT, 10);
51
+ }
52
+ if (app?.pm2_env?.pm_exec_path) {
53
+ const { readFileSync } = await import("node:fs");
54
+ const dir = app.pm2_env.pm_exec_path.replace(/[/\\]run\.sh$/, "");
55
+ const script = readFileSync(dir + "/run.sh", "utf-8");
56
+ const match = script.match(/OPENCLAW_GATEWAY_PORT="?(\d+)/);
57
+ if (match) return parseInt(match[1], 10);
58
+ }
59
+ } catch {}
60
+ return null;
61
+ }
62
+
63
+ export async function listProcesses(): Promise<PM2Process[]> {
64
+ try {
65
+ const { stdout } = await execAsync("pm2 jlist");
66
+ const apps = JSON.parse(stdout);
67
+ return apps.map((p: any) => ({
68
+ name: p.name,
69
+ agentId: p.name.replace(/^gw-/, ""),
70
+ pid: p.pid,
71
+ status: p.pm2_env?.status || "unknown",
72
+ memory: p.monit?.memory || 0,
73
+ cpu: p.monit?.cpu || 0,
74
+ restarts: p.pm2_env?.restart_time || 0,
75
+ uptime: p.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : 0,
76
+ }));
77
+ } catch (err: any) {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ export async function getProcessLogs(name: string): Promise<string> {
83
+ try {
84
+ const { stdout } = await execAsync(
85
+ `pm2 logs ${name} --nostream --lines 100`,
86
+ { timeout: 10_000 },
87
+ );
88
+ return stdout;
89
+ } catch {
90
+ return "(logs unavailable)";
91
+ }
92
+ }
93
+
94
+ export async function restartProcess(name: string): Promise<void> {
95
+ await execAsync("pm2 stop " + name).catch(() => {});
96
+ const port = await getProcessPort(name);
97
+ if (port) await killPort(port);
98
+ await new Promise((r) => setTimeout(r, 1000));
99
+ await execAsync("pm2 restart " + name);
100
+ }
101
+
102
+ export async function stopProcess(name: string): Promise<void> {
103
+ await execAsync("pm2 stop " + name);
104
+ const port = await getProcessPort(name);
105
+ if (port) await killPort(port);
106
+ }
107
+
108
+ export async function startProcess(name: string): Promise<void> {
109
+ const port = await getProcessPort(name);
110
+ if (port) await killPort(port);
111
+ await execAsync("pm2 restart " + name);
112
+ }
113
+
114
+ export async function stopAll(): Promise<void> {
115
+ const procs = await listProcesses();
116
+ await execAsync("pm2 stop all");
117
+ for (const proc of procs) {
118
+ const port = await getProcessPort(proc.name);
119
+ if (port) await killPort(port);
120
+ }
121
+ }
122
+
123
+ export async function startAll(ecosystemPath: string): Promise<void> {
124
+ await execAsync("pm2 start " + ecosystemPath);
125
+ }
@@ -12,6 +12,8 @@ export class MessageRelayClient {
12
12
  private reconnectDelay = 1000;
13
13
  private maxReconnectDelay = 30000;
14
14
  private shouldReconnect = true;
15
+ private authenticated = false;
16
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
15
17
  private pendingCallbacks = new Map<string, {
16
18
  resolve: (msg: any) => void;
17
19
  reject: (err: Error) => void;
@@ -25,14 +27,30 @@ export class MessageRelayClient {
25
27
  }
26
28
 
27
29
  async connect(): Promise<void> {
30
+ // Clean up any existing connection
31
+ if (this.ws) {
32
+ try { this.ws.removeAllListeners(); this.ws.close(); } catch {}
33
+ this.ws = null;
34
+ }
35
+ this.authenticated = false;
36
+
28
37
  return new Promise((resolve, reject) => {
38
+ let settled = false;
39
+ const settle = (fn: Function, arg?: any) => {
40
+ if (!settled) { settled = true; fn(arg); }
41
+ };
42
+
29
43
  try {
30
44
  this.ws = new WebSocket(this.config.url);
31
45
 
46
+ // Auth timeout — if no auth_ok within 10s, fail
47
+ const authTimer = setTimeout(() => {
48
+ settle(reject, new Error('Auth timeout'));
49
+ if (this.ws) { try { this.ws.close(); } catch {} }
50
+ }, 10_000);
51
+
32
52
  this.ws.on('open', () => {
33
53
  this.logger.info('Connected to Message Relay Hub');
34
- this.reconnectDelay = 1000;
35
-
36
54
  this.ws!.send(JSON.stringify({
37
55
  type: 'auth',
38
56
  agentId: this.agentId,
@@ -42,38 +60,30 @@ export class MessageRelayClient {
42
60
 
43
61
  this.ws.on('message', (data) => {
44
62
  let msg: any;
45
- try {
46
- msg = JSON.parse(data.toString());
47
- } catch {
48
- return;
49
- }
63
+ try { msg = JSON.parse(data.toString()); } catch { return; }
50
64
 
51
65
  if (msg.type === 'auth_ok') {
66
+ clearTimeout(authTimer);
67
+ this.authenticated = true;
68
+ this.reconnectDelay = 1000; // Reset on success
52
69
  this.logger.info('Authenticated with Message Relay Hub');
53
- resolve();
70
+ settle(resolve);
54
71
  return;
55
72
  }
56
73
 
57
74
  if (msg.type === 'auth_error') {
75
+ clearTimeout(authTimer);
58
76
  this.logger.error(`Auth failed: ${msg.reason}`);
59
- reject(new Error(msg.reason));
77
+ settle(reject, new Error(msg.reason));
60
78
  return;
61
79
  }
62
80
 
63
- // Check for pending request callbacks
64
- if (msg.replyTo && this.pendingCallbacks.has(msg.replyTo)) {
65
- const cb = this.pendingCallbacks.get(msg.replyTo)!;
81
+ // Check for pending request callbacks (replyTo or sessionId)
82
+ const callbackKey = msg.replyTo || msg.sessionId;
83
+ if (callbackKey && this.pendingCallbacks.has(callbackKey)) {
84
+ const cb = this.pendingCallbacks.get(callbackKey)!;
66
85
  clearTimeout(cb.timer);
67
- this.pendingCallbacks.delete(msg.replyTo);
68
- cb.resolve(msg);
69
- return;
70
- }
71
-
72
- // Also check sessionId for handoff ack
73
- if (msg.sessionId && this.pendingCallbacks.has(msg.sessionId)) {
74
- const cb = this.pendingCallbacks.get(msg.sessionId)!;
75
- clearTimeout(cb.timer);
76
- this.pendingCallbacks.delete(msg.sessionId);
86
+ this.pendingCallbacks.delete(callbackKey);
77
87
  cb.resolve(msg);
78
88
  return;
79
89
  }
@@ -81,12 +91,17 @@ export class MessageRelayClient {
81
91
  // Dispatch to type-based handlers
82
92
  const handlers = this.handlers.get(msg.type) || [];
83
93
  for (const handler of handlers) {
84
- handler(msg);
94
+ try { handler(msg); } catch (e: any) {
95
+ this.logger.error(`Handler error for ${msg.type}: ${e.message}`);
96
+ }
85
97
  }
86
98
  });
87
99
 
88
- this.ws.on('close', () => {
89
- this.logger.info('Disconnected from Message Relay Hub');
100
+ this.ws.on('close', (code) => {
101
+ clearTimeout(authTimer);
102
+ this.authenticated = false;
103
+ this.logger.info(`Disconnected from Hub (code: ${code})`);
104
+ settle(reject, new Error('Connection closed'));
90
105
  if (this.shouldReconnect) {
91
106
  this.scheduleReconnect();
92
107
  }
@@ -94,19 +109,27 @@ export class MessageRelayClient {
94
109
 
95
110
  this.ws.on('error', (err) => {
96
111
  this.logger.error(`WebSocket error: ${err.message}`);
112
+ // Don't settle here — let 'close' event handle it
97
113
  });
98
114
  } catch (err: any) {
99
- reject(err);
115
+ settle(reject, err);
100
116
  }
101
117
  });
102
118
  }
103
119
 
104
120
  private scheduleReconnect(): void {
121
+ // Prevent multiple reconnect timers
122
+ if (this.reconnectTimer) return;
105
123
  this.logger.info(`Reconnecting in ${this.reconnectDelay}ms...`);
106
- setTimeout(() => {
107
- this.connect().catch(() => {
124
+ this.reconnectTimer = setTimeout(async () => {
125
+ this.reconnectTimer = null;
126
+ try {
127
+ await this.connect();
128
+ this.logger.info('Reconnected to Message Relay Hub');
129
+ } catch {
108
130
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
109
- });
131
+ // connect() failure triggers 'close' which calls scheduleReconnect again
132
+ }
110
133
  }, this.reconnectDelay);
111
134
  }
112
135
 
@@ -117,7 +140,7 @@ export class MessageRelayClient {
117
140
  }
118
141
 
119
142
  send(msg: any): void {
120
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
143
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.authenticated) {
121
144
  this.ws.send(JSON.stringify(msg));
122
145
  }
123
146
  }
@@ -137,6 +160,10 @@ export class MessageRelayClient {
137
160
 
138
161
  async disconnect(): Promise<void> {
139
162
  this.shouldReconnect = false;
163
+ if (this.reconnectTimer) {
164
+ clearTimeout(this.reconnectTimer);
165
+ this.reconnectTimer = null;
166
+ }
140
167
  for (const [id, cb] of this.pendingCallbacks.entries()) {
141
168
  clearTimeout(cb.timer);
142
169
  cb.reject(new Error('Disconnecting'));
@@ -144,12 +171,14 @@ export class MessageRelayClient {
144
171
  this.pendingCallbacks.clear();
145
172
 
146
173
  if (this.ws) {
174
+ this.ws.removeAllListeners();
147
175
  this.ws.close(1000, 'plugin deactivated');
148
176
  this.ws = null;
149
177
  }
178
+ this.authenticated = false;
150
179
  }
151
180
 
152
181
  get isConnected(): boolean {
153
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
182
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.authenticated;
154
183
  }
155
184
  }