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/README.md +141 -0
- package/openclaw.plugin.json +31 -1
- package/package.json +4 -1
- package/src/cli.ts +842 -0
- package/src/config.ts +9 -0
- package/src/heartbeat.ts +26 -0
- package/src/index.ts +36 -1
- package/src/manager/hub-client.ts +114 -0
- package/src/manager/local-manager.ts +121 -0
- package/src/manager/pm2-bridge.ts +125 -0
- package/src/message-relay.ts +60 -31
- package/src/types.ts +12 -0
- package/_inbox/main/bridge-test.md +0 -1
- package/_inbox/pm/bridge-test.md +0 -1
- package/output/bridge-test.md +0 -1
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
|
|
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
|
+
}
|
package/src/message-relay.ts
CHANGED
|
@@ -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
|
|
77
|
+
settle(reject, new Error(msg.reason));
|
|
60
78
|
return;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
// Check for pending request callbacks
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
}
|