openclaw-bridge 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.
@@ -0,0 +1,155 @@
1
+ import WebSocket from 'ws';
2
+ import type { PluginLogger, MessageRelayConfig } from './types.js';
3
+
4
+ type MessageHandler = (msg: any) => void;
5
+
6
+ export class MessageRelayClient {
7
+ private ws: WebSocket | null = null;
8
+ private agentId: string;
9
+ private config: MessageRelayConfig;
10
+ private logger: PluginLogger;
11
+ private handlers = new Map<string, MessageHandler[]>();
12
+ private reconnectDelay = 1000;
13
+ private maxReconnectDelay = 30000;
14
+ private shouldReconnect = true;
15
+ private pendingCallbacks = new Map<string, {
16
+ resolve: (msg: any) => void;
17
+ reject: (err: Error) => void;
18
+ timer: ReturnType<typeof setTimeout>;
19
+ }>();
20
+
21
+ constructor(agentId: string, config: MessageRelayConfig, logger: PluginLogger) {
22
+ this.agentId = agentId;
23
+ this.config = config;
24
+ this.logger = logger;
25
+ }
26
+
27
+ async connect(): Promise<void> {
28
+ return new Promise((resolve, reject) => {
29
+ try {
30
+ this.ws = new WebSocket(this.config.url);
31
+
32
+ this.ws.on('open', () => {
33
+ this.logger.info('Connected to Message Relay Hub');
34
+ this.reconnectDelay = 1000;
35
+
36
+ this.ws!.send(JSON.stringify({
37
+ type: 'auth',
38
+ agentId: this.agentId,
39
+ apiKey: this.config.apiKey,
40
+ }));
41
+ });
42
+
43
+ this.ws.on('message', (data) => {
44
+ let msg: any;
45
+ try {
46
+ msg = JSON.parse(data.toString());
47
+ } catch {
48
+ return;
49
+ }
50
+
51
+ if (msg.type === 'auth_ok') {
52
+ this.logger.info('Authenticated with Message Relay Hub');
53
+ resolve();
54
+ return;
55
+ }
56
+
57
+ if (msg.type === 'auth_error') {
58
+ this.logger.error(`Auth failed: ${msg.reason}`);
59
+ reject(new Error(msg.reason));
60
+ return;
61
+ }
62
+
63
+ // Check for pending request callbacks
64
+ if (msg.replyTo && this.pendingCallbacks.has(msg.replyTo)) {
65
+ const cb = this.pendingCallbacks.get(msg.replyTo)!;
66
+ 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);
77
+ cb.resolve(msg);
78
+ return;
79
+ }
80
+
81
+ // Dispatch to type-based handlers
82
+ const handlers = this.handlers.get(msg.type) || [];
83
+ for (const handler of handlers) {
84
+ handler(msg);
85
+ }
86
+ });
87
+
88
+ this.ws.on('close', () => {
89
+ this.logger.info('Disconnected from Message Relay Hub');
90
+ if (this.shouldReconnect) {
91
+ this.scheduleReconnect();
92
+ }
93
+ });
94
+
95
+ this.ws.on('error', (err) => {
96
+ this.logger.error(`WebSocket error: ${err.message}`);
97
+ });
98
+ } catch (err: any) {
99
+ reject(err);
100
+ }
101
+ });
102
+ }
103
+
104
+ private scheduleReconnect(): void {
105
+ this.logger.info(`Reconnecting in ${this.reconnectDelay}ms...`);
106
+ setTimeout(() => {
107
+ this.connect().catch(() => {
108
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
109
+ });
110
+ }, this.reconnectDelay);
111
+ }
112
+
113
+ on(type: string, handler: MessageHandler): void {
114
+ const existing = this.handlers.get(type) || [];
115
+ existing.push(handler);
116
+ this.handlers.set(type, existing);
117
+ }
118
+
119
+ send(msg: any): void {
120
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
121
+ this.ws.send(JSON.stringify(msg));
122
+ }
123
+ }
124
+
125
+ async sendAndWait(msg: any, timeoutMs = 60_000): Promise<any> {
126
+ return new Promise((resolve, reject) => {
127
+ const id = msg.id || msg.sessionId;
128
+ const timer = setTimeout(() => {
129
+ this.pendingCallbacks.delete(id);
130
+ reject(new Error(`Timeout waiting for reply to ${id}`));
131
+ }, timeoutMs);
132
+
133
+ this.pendingCallbacks.set(id, { resolve, reject, timer });
134
+ this.send(msg);
135
+ });
136
+ }
137
+
138
+ async disconnect(): Promise<void> {
139
+ this.shouldReconnect = false;
140
+ for (const [id, cb] of this.pendingCallbacks.entries()) {
141
+ clearTimeout(cb.timer);
142
+ cb.reject(new Error('Disconnecting'));
143
+ }
144
+ this.pendingCallbacks.clear();
145
+
146
+ if (this.ws) {
147
+ this.ws.close(1000, 'plugin deactivated');
148
+ this.ws = null;
149
+ }
150
+ }
151
+
152
+ get isConnected(): boolean {
153
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
154
+ }
155
+ }
@@ -0,0 +1,18 @@
1
+ import type { BridgeConfig } from "./types.js";
2
+
3
+ const PUBLIC_ACTIONS = new Set(["discover", "whois", "send_file"]);
4
+ const SUPERUSER_ACTIONS = new Set(["read_file", "write_file", "restart"]);
5
+
6
+ export function checkPermission(action: string, config: BridgeConfig): boolean {
7
+ if (PUBLIC_ACTIONS.has(action)) return true;
8
+ if (SUPERUSER_ACTIONS.has(action)) return config.role === "superuser";
9
+ return false;
10
+ }
11
+
12
+ export function assertPermission(action: string, config: BridgeConfig): void {
13
+ if (!checkPermission(action, config)) {
14
+ throw new Error(
15
+ `openclaw-bridge: permission denied — "${action}" requires superuser role, current role is "${config.role}"`,
16
+ );
17
+ }
18
+ }
@@ -0,0 +1,107 @@
1
+ import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
2
+
3
+ export class BridgeRegistry {
4
+ private baseUrl: string;
5
+ private apiKey: string | undefined;
6
+ private logger: PluginLogger;
7
+
8
+ constructor(config: BridgeConfig, logger: PluginLogger) {
9
+ // Use fileRelay URL for registry (not OpenViking)
10
+ if (!config.fileRelay?.baseUrl) {
11
+ throw new Error("openclaw-bridge: fileRelay.baseUrl is required for registry");
12
+ }
13
+ this.baseUrl = config.fileRelay.baseUrl.replace(/\/+$/, "");
14
+ this.apiKey = config.fileRelay.apiKey;
15
+ this.logger = logger;
16
+ }
17
+
18
+ private headers(): Record<string, string> {
19
+ const h: Record<string, string> = { "Content-Type": "application/json" };
20
+ if (this.apiKey) h["X-API-Key"] = this.apiKey;
21
+ return h;
22
+ }
23
+
24
+ async register(entry: RegistryEntry): Promise<void> {
25
+ try {
26
+ const res = await fetch(`${this.baseUrl}/api/v1/registry/register`, {
27
+ method: "POST",
28
+ headers: this.headers(),
29
+ body: JSON.stringify(entry),
30
+ });
31
+ if (!res.ok) {
32
+ throw new Error(`HTTP ${res.status}`);
33
+ }
34
+ this.logger.info(`openclaw-bridge: registered ${entry.agentId} to registry`);
35
+ } catch (err) {
36
+ this.logger.error(`openclaw-bridge: registration failed: ${String(err)}`);
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ async update(entry: RegistryEntry): Promise<void> {
42
+ try {
43
+ const res = await fetch(`${this.baseUrl}/api/v1/registry/heartbeat`, {
44
+ method: "POST",
45
+ headers: this.headers(),
46
+ body: JSON.stringify(entry),
47
+ });
48
+ if (!res.ok) {
49
+ // Re-register if heartbeat fails
50
+ await this.register(entry);
51
+ }
52
+ } catch (err) {
53
+ this.logger.warn(`openclaw-bridge: heartbeat failed, re-registering: ${String(err)}`);
54
+ await this.register(entry);
55
+ }
56
+ }
57
+
58
+ async deregister(agentId: string): Promise<void> {
59
+ try {
60
+ await fetch(`${this.baseUrl}/api/v1/registry/${agentId}`, {
61
+ method: "DELETE",
62
+ headers: this.headers(),
63
+ });
64
+ this.logger.info(`openclaw-bridge: deregistered ${agentId}`);
65
+ } catch (err) {
66
+ this.logger.warn(`openclaw-bridge: deregistration failed: ${String(err)}`);
67
+ }
68
+ }
69
+
70
+ async discover(offlineThresholdMs: number): Promise<RegistryEntry[]> {
71
+ try {
72
+ const res = await fetch(`${this.baseUrl}/api/v1/registry/discover`, {
73
+ headers: this.headers(),
74
+ });
75
+ if (!res.ok) return [];
76
+
77
+ const data = (await res.json()) as { agents?: RegistryEntry[] };
78
+ const now = Date.now();
79
+
80
+ return (data.agents ?? []).map((entry) => {
81
+ const lastBeat = new Date(entry.lastHeartbeat).getTime();
82
+ entry.status = (now - lastBeat > offlineThresholdMs) ? "offline" : "online";
83
+ return entry;
84
+ });
85
+ } catch (err) {
86
+ this.logger.error(`openclaw-bridge: discover failed: ${String(err)}`);
87
+ return [];
88
+ }
89
+ }
90
+
91
+ async findAgent(agentId: string, offlineThresholdMs: number): Promise<RegistryEntry | null> {
92
+ try {
93
+ const res = await fetch(`${this.baseUrl}/api/v1/registry/whois/${agentId}`, {
94
+ headers: this.headers(),
95
+ });
96
+ if (!res.ok) return null;
97
+
98
+ const entry = (await res.json()) as RegistryEntry;
99
+ const now = Date.now();
100
+ const lastBeat = new Date(entry.lastHeartbeat).getTime();
101
+ entry.status = (now - lastBeat > offlineThresholdMs) ? "offline" : "online";
102
+ return entry;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ }
package/src/restart.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { BridgeConfig, RegistryEntry, PluginLogger } from "./types.js";
4
+ import type { BridgeRegistry } from "./registry.js";
5
+
6
+ const execAsync = promisify(exec);
7
+ const IS_WIN = process.platform === "win32";
8
+
9
+ export class BridgeRestart {
10
+ private config: BridgeConfig;
11
+ private machineId: string;
12
+ private registry: BridgeRegistry;
13
+ private logger: PluginLogger;
14
+
15
+ constructor(
16
+ config: BridgeConfig,
17
+ machineId: string,
18
+ registry: BridgeRegistry,
19
+ logger: PluginLogger,
20
+ ) {
21
+ this.config = config;
22
+ this.machineId = machineId;
23
+ this.registry = registry;
24
+ this.logger = logger;
25
+ }
26
+
27
+ async restart(target: RegistryEntry): Promise<{ success: boolean; message: string }> {
28
+ if (target.machineId !== this.machineId) {
29
+ return this.restartRemote(target);
30
+ }
31
+ return this.restartLocal(target);
32
+ }
33
+
34
+ private async restartLocal(
35
+ target: RegistryEntry,
36
+ ): Promise<{ success: boolean; message: string }> {
37
+ this.logger.info(`openclaw-bridge: restarting ${target.agentId} on local machine`);
38
+
39
+ try {
40
+ if (IS_WIN) {
41
+ const { stdout } = await execAsync(
42
+ `netstat -ano | findstr :${target.port} | findstr LISTENING`,
43
+ );
44
+ const lines = stdout.trim().split("\n");
45
+ for (const line of lines) {
46
+ const pid = line.trim().split(/\s+/).pop();
47
+ if (pid && /^\d+$/.test(pid)) {
48
+ await execAsync(`taskkill /F /PID ${pid}`).catch(() => {});
49
+ }
50
+ }
51
+ } else {
52
+ await execAsync(`lsof -ti:${target.port} | xargs kill -9`).catch(() => {});
53
+ }
54
+
55
+ const instanceDir = target.workspacePath.replace(/[\\/]workspace[\\/]?$/, "");
56
+ const runScript = IS_WIN
57
+ ? `${instanceDir}\\run.ps1`
58
+ : `${instanceDir}/run.sh`;
59
+
60
+ if (IS_WIN) {
61
+ await execAsync(
62
+ `powershell -Command "Start-Process powershell -ArgumentList '-File','${runScript}' -WindowStyle Hidden"`,
63
+ );
64
+ } else {
65
+ await execAsync(`nohup bash "${runScript}" > /dev/null 2>&1 &`);
66
+ }
67
+
68
+ const deadline = Date.now() + 60_000;
69
+ while (Date.now() < deadline) {
70
+ await new Promise((r) => setTimeout(r, 3_000));
71
+ const found = await this.registry.findAgent(
72
+ target.agentId,
73
+ this.config.offlineThresholdMs ?? 120_000,
74
+ );
75
+ if (found && found.status === "online") {
76
+ this.logger.info(`openclaw-bridge: ${target.agentId} restarted successfully`);
77
+ return { success: true, message: `${target.agentId} restarted and back online` };
78
+ }
79
+ }
80
+
81
+ return {
82
+ success: false,
83
+ message: `${target.agentId} process started but did not re-register within 60s`,
84
+ };
85
+ } catch (err) {
86
+ return { success: false, message: `Restart failed: ${String(err)}` };
87
+ }
88
+ }
89
+
90
+ private async restartRemote(
91
+ target: RegistryEntry,
92
+ ): Promise<{ success: boolean; message: string }> {
93
+ if (!this.config.fileRelay?.baseUrl) {
94
+ return {
95
+ success: false,
96
+ message: "Cannot restart remote gateway: fileRelay not configured",
97
+ };
98
+ }
99
+
100
+ const baseUrl = this.config.fileRelay.baseUrl.replace(/\/+$/, "");
101
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
102
+ if (this.config.fileRelay.apiKey) headers["X-API-Key"] = this.config.fileRelay.apiKey;
103
+
104
+ const res = await fetch(`${baseUrl}/api/v1/commands/enqueue`, {
105
+ method: "POST",
106
+ headers,
107
+ body: JSON.stringify({
108
+ fromAgent: this.config.agentId,
109
+ toAgent: target.agentId,
110
+ type: "restart",
111
+ payload: {},
112
+ }),
113
+ });
114
+
115
+ if (!res.ok) {
116
+ return { success: false, message: `FileRelay command enqueue failed: ${res.status}` };
117
+ }
118
+
119
+ const { id: cmdId } = (await res.json()) as { id: string };
120
+
121
+ const deadline = Date.now() + 90_000;
122
+ while (Date.now() < deadline) {
123
+ await new Promise((r) => setTimeout(r, 5_000));
124
+ const resultRes = await fetch(`${baseUrl}/api/v1/commands/result/${cmdId}`, { headers });
125
+ if (!resultRes.ok) continue;
126
+ const result = (await resultRes.json()) as { status: string };
127
+ if (result.status === "ok") {
128
+ return { success: true, message: `Restart command acknowledged by ${target.agentId}` };
129
+ }
130
+ if (result.status === "error") {
131
+ return { success: false, message: `Remote restart failed` };
132
+ }
133
+ }
134
+
135
+ return { success: false, message: "Restart command timed out (90s)" };
136
+ }
137
+ }
package/src/router.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { RegistryEntry, ChannelInfo } from './types.js';
2
+
3
+ export interface MessageContext {
4
+ channel: ChannelInfo;
5
+ isGroupChannel: boolean;
6
+ }
7
+
8
+ export interface RouteDecision {
9
+ method: 'channel_direct' | 'hub_relay';
10
+ channel?: ChannelInfo;
11
+ fallback: 'hub_relay';
12
+ }
13
+
14
+ export function decideRoute(
15
+ currentContext: MessageContext,
16
+ targetAgentId: string,
17
+ registry: RegistryEntry[]
18
+ ): RouteDecision {
19
+ const target = registry.find(a => a.agentId === targetAgentId);
20
+ if (!target || target.status !== 'online') {
21
+ return { method: 'hub_relay', fallback: 'hub_relay' };
22
+ }
23
+
24
+ if (currentContext.isGroupChannel && target.channels) {
25
+ const targetInSameChannel = target.channels.some(
26
+ ch => ch.type === currentContext.channel.type
27
+ && ch.channelId === currentContext.channel.channelId
28
+ );
29
+
30
+ if (targetInSameChannel) {
31
+ return {
32
+ method: 'channel_direct',
33
+ channel: currentContext.channel,
34
+ fallback: 'hub_relay',
35
+ };
36
+ }
37
+ }
38
+
39
+ return { method: 'hub_relay', fallback: 'hub_relay' };
40
+ }
package/src/session.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface ProxySession {
2
+ sessionId: string;
3
+ originAgent: string;
4
+ currentAgent: string;
5
+ currentAgentName: string;
6
+ }
7
+
8
+ let activeSession: ProxySession | null = null;
9
+
10
+ export function setSession(session: ProxySession): void {
11
+ activeSession = session;
12
+ console.log(`[session] SET handoff: sessionId=${session.sessionId} target=${session.currentAgent}`);
13
+ }
14
+
15
+ export function getSession(): ProxySession | null {
16
+ return activeSession;
17
+ }
18
+
19
+ export function clearSession(): void {
20
+ console.log(`[session] CLEAR handoff (was: ${activeSession?.sessionId || 'none'})`);
21
+ activeSession = null;
22
+ }
23
+
24
+ export function updateCurrentAgent(agentId: string, agentName: string): void {
25
+ if (activeSession) {
26
+ activeSession.currentAgent = agentId;
27
+ activeSession.currentAgentName = agentName;
28
+ }
29
+ }
30
+
31
+ export function isInHandoff(): boolean {
32
+ return activeSession !== null;
33
+ }
package/src/types.ts ADDED
@@ -0,0 +1,87 @@
1
+ export interface MessageRelayConfig {
2
+ url: string; // ws://host:3080/ws
3
+ apiKey: string;
4
+ }
5
+
6
+ export interface BridgeConfig {
7
+ role: "normal" | "superuser";
8
+ agentId: string;
9
+ agentName: string;
10
+ registry: {
11
+ provider?: string;
12
+ baseUrl: string;
13
+ apiKey?: string;
14
+ };
15
+ fileRelay?: {
16
+ baseUrl: string;
17
+ apiKey?: string;
18
+ };
19
+ messageRelay?: MessageRelayConfig;
20
+ heartbeatIntervalMs?: number;
21
+ offlineThresholdMs?: number;
22
+ }
23
+
24
+ export interface ChannelInfo {
25
+ type: string; // "discord", "slack", "web", etc.
26
+ channelId: string; // platform-specific channel identifier
27
+ name: string; // human-readable name, e.g. "#creators", "DM-user1"
28
+ }
29
+
30
+ export interface RegistryEntry {
31
+ type: "gateway-registry";
32
+ agentId: string;
33
+ agentName: string;
34
+ machineId: string;
35
+ host: string;
36
+ port: number;
37
+ workspacePath: string;
38
+ discordId: string | null;
39
+ role: "normal" | "superuser";
40
+ capabilities: string[];
41
+ channels: ChannelInfo[];
42
+ registeredAt: string;
43
+ lastHeartbeat: string;
44
+ status: "online" | "offline";
45
+ }
46
+
47
+ export interface DiscoverResult {
48
+ agents: RegistryEntry[];
49
+ }
50
+
51
+ export type PluginLogger = {
52
+ debug?: (message: string) => void;
53
+ info: (message: string) => void;
54
+ warn: (message: string) => void;
55
+ error: (message: string) => void;
56
+ };
57
+
58
+ export type HookAgentContext = {
59
+ agentId?: string;
60
+ sessionId?: string;
61
+ sessionKey?: string;
62
+ };
63
+
64
+ export type OpenClawPluginApi = {
65
+ pluginConfig?: unknown;
66
+ logger: PluginLogger;
67
+ registerTool: (
68
+ tool: {
69
+ name: string;
70
+ label: string;
71
+ description: string;
72
+ parameters: unknown;
73
+ execute: (_toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
74
+ },
75
+ opts?: { name?: string; names?: string[] },
76
+ ) => void;
77
+ registerService: (service: {
78
+ id: string;
79
+ start: (ctx?: unknown) => void | Promise<void>;
80
+ stop?: (ctx?: unknown) => void | Promise<void>;
81
+ }) => void;
82
+ on: (
83
+ hookName: string,
84
+ handler: (event: unknown, ctx?: HookAgentContext) => unknown,
85
+ opts?: { priority?: number },
86
+ ) => void;
87
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }