openclaw-bridge 0.4.9 → 0.5.2
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 +14 -0
- package/dist/circuit-breaker.d.ts +17 -0
- package/dist/circuit-breaker.js +51 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +32 -2
- package/dist/discord-api.d.ts +24 -0
- package/dist/discord-api.js +83 -0
- package/dist/heartbeat.js +8 -3
- package/dist/index.js +375 -33
- package/dist/manager/hub-client.js +11 -2
- package/dist/mention-interceptor.d.ts +16 -0
- package/dist/mention-interceptor.js +43 -0
- package/dist/message-relay.js +8 -1
- package/dist/permissions.js +7 -1
- package/dist/project-manager.d.ts +30 -0
- package/dist/project-manager.js +257 -0
- package/dist/types.d.ts +47 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,20 @@ openclaw-bridge upgrade # updates both plugin and CLI automatically
|
|
|
39
39
|
|
|
40
40
|
**Prerequisites:** [openclaw-bridge-hub](https://www.npmjs.com/package/openclaw-bridge-hub) running on a server, PM2 installed globally (`npm install -g pm2`), Node.js 18+.
|
|
41
41
|
|
|
42
|
+
## What's New in v0.5.1
|
|
43
|
+
|
|
44
|
+
### Bug Fixes
|
|
45
|
+
- **Conflict Rename Re-registration** — After agentId conflict rename, the plugin now re-registers with the new ID immediately. Previously, the old ID was deregistered but the new ID was not registered until the next heartbeat, leaving the agent invisible on Hub.
|
|
46
|
+
- **Channel Auto-Detection** — Fixed `discordId` and `channels` always showing as `null` / `[]` on Hub. Root causes: (1) config path had no fallback when `OPENCLAW_CONFIG_PATH` env was unset, (2) `extractChannels()` treated `accounts` as Array instead of Record (always returned `[]`), (3) detection only ran on heartbeat tick, not at startup. All three fixed.
|
|
47
|
+
- **WebSocket Reconnection** — Fixed reconnection stopping permanently if `new WebSocket()` threw synchronously (e.g., DNS failure). Now schedules retry in the catch block.
|
|
48
|
+
- **Stable Machine ID** — `getMachineId()` now persists a stable ID to `~/.openclaw/.machine-id` instead of using `os.hostname()`. Prevents ghost nodes on Hub when macOS hostname changes (hostname vs LocalHostName mismatch).
|
|
49
|
+
|
|
50
|
+
### v0.5.1 Bug 修复
|
|
51
|
+
- **冲突重命名后重新注册** — agentId 冲突重命名后,现在立即用新 ID 重新注册。此前旧 ID 被注销但新 ID 要等下一次心跳才注册,导致 Hub 上看不到该节点。
|
|
52
|
+
- **Channel 自动检测** — 修复 Hub 上 `discordId` 和 `channels` 始终显示为 `null` / `[]` 的问题。根因:(1) 没设 `OPENCLAW_CONFIG_PATH` 环境变量时 config 路径无 fallback,(2) `extractChannels()` 把 `accounts` 当数组解析(实际是对象),(3) 检测只在心跳 tick 运行不在启动时运行。三个问题全部修复。
|
|
53
|
+
- **WebSocket 重连** — 修复 `new WebSocket()` 同步抛异常(如 DNS 解析失败)时重连永久停止的问题。
|
|
54
|
+
- **稳定机器 ID** — `getMachineId()` 现在将稳定 ID 持久化到 `~/.openclaw/.machine-id`,不再依赖 `os.hostname()`。防止 macOS 主机名变化时 Hub 上产生幽灵节点。
|
|
55
|
+
|
|
42
56
|
## What's New in v0.4.0
|
|
43
57
|
|
|
44
58
|
### Multi-Device Support
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProjectManager } from "./project-manager.js";
|
|
2
|
+
import type { PluginLogger } from "./types.js";
|
|
3
|
+
export interface CircuitBreakerResult {
|
|
4
|
+
allowed: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
level?: "soft" | "hard" | "project";
|
|
7
|
+
}
|
|
8
|
+
export declare class CircuitBreaker {
|
|
9
|
+
private pm;
|
|
10
|
+
private logger;
|
|
11
|
+
constructor(pm: ProjectManager, logger: PluginLogger);
|
|
12
|
+
/**
|
|
13
|
+
* Check and increment rounds for a task. Returns whether communication is allowed.
|
|
14
|
+
* This is the HARD enforcement layer — overrides PM judgment.
|
|
15
|
+
*/
|
|
16
|
+
checkAndIncrement(projectId: string, taskId: string): CircuitBreakerResult;
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const HARD_LIMIT_PER_TASK = 15;
|
|
2
|
+
const PROJECT_TOTAL_LIMIT = 30;
|
|
3
|
+
export class CircuitBreaker {
|
|
4
|
+
pm;
|
|
5
|
+
logger;
|
|
6
|
+
constructor(pm, logger) {
|
|
7
|
+
this.pm = pm;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Check and increment rounds for a task. Returns whether communication is allowed.
|
|
12
|
+
* This is the HARD enforcement layer — overrides PM judgment.
|
|
13
|
+
*/
|
|
14
|
+
checkAndIncrement(projectId, taskId) {
|
|
15
|
+
const result = this.pm.incrementRounds(projectId, taskId);
|
|
16
|
+
if (!result)
|
|
17
|
+
return { allowed: true };
|
|
18
|
+
const project = this.pm.readProject(projectId);
|
|
19
|
+
// Hard per-task limit (15 rounds) — non-negotiable
|
|
20
|
+
if (result.hardLimit) {
|
|
21
|
+
this.logger.warn(`[circuit-breaker] HARD LIMIT: task ${taskId} in project ${projectId} hit ${result.task.rounds} rounds`);
|
|
22
|
+
this.pm.updateTaskStatus(projectId, taskId, "blocked", {
|
|
23
|
+
blockType: "dependency_failed",
|
|
24
|
+
blockReason: `Circuit breaker: task exceeded ${HARD_LIMIT_PER_TASK} rounds`,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
allowed: false,
|
|
28
|
+
reason: `Task ${taskId} force-stopped: exceeded ${HARD_LIMIT_PER_TASK} communication rounds. Human intervention required.`,
|
|
29
|
+
level: "hard",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Project total limit (30 rounds)
|
|
33
|
+
if (project && project.totalRounds >= PROJECT_TOTAL_LIMIT) {
|
|
34
|
+
this.logger.warn(`[circuit-breaker] PROJECT LIMIT: project ${projectId} hit ${project.totalRounds} total rounds`);
|
|
35
|
+
return {
|
|
36
|
+
allowed: false,
|
|
37
|
+
reason: `Project "${project.name}" total communication exceeded ${PROJECT_TOTAL_LIMIT} rounds. Pausing for human review.`,
|
|
38
|
+
level: "project",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Soft per-task limit (8 rounds) — warning, PM should handle
|
|
42
|
+
if (result.softLimit) {
|
|
43
|
+
return {
|
|
44
|
+
allowed: true,
|
|
45
|
+
reason: `Warning: task ${taskId} has reached ${result.task.rounds} rounds (soft limit: ${result.task.maxRounds})`,
|
|
46
|
+
level: "soft",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { allowed: true };
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { BridgeConfig } from "./types.js";
|
|
2
2
|
export declare function parseConfig(raw: unknown): BridgeConfig;
|
|
3
|
+
/**
|
|
4
|
+
* Returns a stable machine identifier.
|
|
5
|
+
* Persists a UUID to ~/.openclaw/.machine-id on first call so the ID
|
|
6
|
+
* survives hostname changes (common on macOS where hostname ≠ LocalHostName).
|
|
7
|
+
* Falls back to hostname() if the file cannot be written.
|
|
8
|
+
*/
|
|
3
9
|
export declare function getMachineId(): string;
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { hostname } from "node:os";
|
|
1
|
+
import { hostname, homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
2
5
|
const DEFAULTS = {
|
|
3
6
|
heartbeatIntervalMs: 30_000,
|
|
4
7
|
offlineThresholdMs: 120_000,
|
|
@@ -59,6 +62,33 @@ export function parseConfig(raw) {
|
|
|
59
62
|
: undefined,
|
|
60
63
|
};
|
|
61
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns a stable machine identifier.
|
|
67
|
+
* Persists a UUID to ~/.openclaw/.machine-id on first call so the ID
|
|
68
|
+
* survives hostname changes (common on macOS where hostname ≠ LocalHostName).
|
|
69
|
+
* Falls back to hostname() if the file cannot be written.
|
|
70
|
+
*/
|
|
62
71
|
export function getMachineId() {
|
|
63
|
-
|
|
72
|
+
const dir = join(homedir(), ".openclaw");
|
|
73
|
+
const idFile = join(dir, ".machine-id");
|
|
74
|
+
try {
|
|
75
|
+
const existing = readFileSync(idFile, "utf-8").trim();
|
|
76
|
+
if (existing)
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// File doesn't exist yet — generate one
|
|
81
|
+
}
|
|
82
|
+
// Use hostname as the base for readability, but append a short UUID suffix
|
|
83
|
+
// so renames don't change the identity
|
|
84
|
+
const id = `${hostname()}-${randomUUID().slice(0, 8)}`;
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
writeFileSync(idFile, id, "utf-8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Can't persist — fall back to hostname
|
|
91
|
+
return hostname();
|
|
92
|
+
}
|
|
93
|
+
return id;
|
|
64
94
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginLogger } from "./types.js";
|
|
2
|
+
export declare class DiscordApi {
|
|
3
|
+
private token;
|
|
4
|
+
private logger;
|
|
5
|
+
constructor(logger: PluginLogger);
|
|
6
|
+
/** Extract bot token from openclaw.json config */
|
|
7
|
+
loadToken(): void;
|
|
8
|
+
get isAvailable(): boolean;
|
|
9
|
+
private request;
|
|
10
|
+
/**
|
|
11
|
+
* Create a public thread in a channel.
|
|
12
|
+
* Returns { id, name } of the created thread.
|
|
13
|
+
*/
|
|
14
|
+
createThread(channelId: string, name: string, message?: string): Promise<{
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Send a message to a channel or thread.
|
|
20
|
+
*/
|
|
21
|
+
sendMessage(channelId: string, content: string): Promise<{
|
|
22
|
+
id: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const DISCORD_API = "https://discord.com/api/v10";
|
|
5
|
+
export class DiscordApi {
|
|
6
|
+
token = null;
|
|
7
|
+
logger;
|
|
8
|
+
constructor(logger) {
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
/** Extract bot token from openclaw.json config */
|
|
12
|
+
loadToken() {
|
|
13
|
+
const configCandidates = [
|
|
14
|
+
process.env.OPENCLAW_CONFIG_PATH,
|
|
15
|
+
process.env.OPENCLAW_HOME ? `${process.env.OPENCLAW_HOME}/openclaw.json` : "",
|
|
16
|
+
join(homedir(), ".openclaw", "openclaw.json"),
|
|
17
|
+
].filter(Boolean);
|
|
18
|
+
for (const configPath of configCandidates) {
|
|
19
|
+
if (!existsSync(configPath))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
23
|
+
const accounts = raw.channels?.discord?.accounts ?? raw.channels?.["openclaw-discord"]?.accounts ?? {};
|
|
24
|
+
for (const acc of Object.values(accounts)) {
|
|
25
|
+
if (acc.token) {
|
|
26
|
+
this.token = acc.token;
|
|
27
|
+
this.logger.info("[discord-api] Bot token loaded");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { /* try next */ }
|
|
33
|
+
}
|
|
34
|
+
if (!this.token) {
|
|
35
|
+
this.logger.warn("[discord-api] No Discord bot token found — thread tools will be unavailable");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
get isAvailable() {
|
|
39
|
+
return this.token !== null;
|
|
40
|
+
}
|
|
41
|
+
async request(method, path, body) {
|
|
42
|
+
if (!this.token)
|
|
43
|
+
throw new Error("Discord API not available — no bot token");
|
|
44
|
+
const res = await fetch(`${DISCORD_API}${path}`, {
|
|
45
|
+
method,
|
|
46
|
+
headers: {
|
|
47
|
+
"Authorization": `Bot ${this.token}`,
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
throw new Error(`Discord API ${method} ${path} failed: ${res.status} ${text.substring(0, 200)}`);
|
|
55
|
+
}
|
|
56
|
+
return res.json();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create a public thread in a channel.
|
|
60
|
+
* Returns { id, name } of the created thread.
|
|
61
|
+
*/
|
|
62
|
+
async createThread(channelId, name, message) {
|
|
63
|
+
const thread = await this.request("POST", `/channels/${channelId}/threads`, {
|
|
64
|
+
name: name.substring(0, 100),
|
|
65
|
+
type: 11, // PUBLIC_THREAD
|
|
66
|
+
auto_archive_duration: 10080, // 7 days
|
|
67
|
+
});
|
|
68
|
+
if (message) {
|
|
69
|
+
await this.sendMessage(thread.id, message);
|
|
70
|
+
}
|
|
71
|
+
this.logger.info(`[discord-api] Created thread "${name}" (${thread.id})`);
|
|
72
|
+
return { id: thread.id, name: thread.name };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Send a message to a channel or thread.
|
|
76
|
+
*/
|
|
77
|
+
async sendMessage(channelId, content) {
|
|
78
|
+
const msg = await this.request("POST", `/channels/${channelId}/messages`, {
|
|
79
|
+
content: content.substring(0, 2000),
|
|
80
|
+
});
|
|
81
|
+
return { id: msg.id };
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/heartbeat.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
4
6
|
export class BridgeHeartbeat {
|
|
5
7
|
config;
|
|
6
8
|
registry;
|
|
@@ -16,7 +18,8 @@ export class BridgeHeartbeat {
|
|
|
16
18
|
this.fileOps = fileOps;
|
|
17
19
|
this.entry = entry;
|
|
18
20
|
this.logger = logger;
|
|
19
|
-
this.configPath = process.env.OPENCLAW_CONFIG_PATH
|
|
21
|
+
this.configPath = process.env.OPENCLAW_CONFIG_PATH
|
|
22
|
+
|| join(homedir(), '.openclaw', 'openclaw.json');
|
|
20
23
|
this.lastConfigHash = this.computeEntryHash();
|
|
21
24
|
}
|
|
22
25
|
computeEntryHash() {
|
|
@@ -32,6 +35,8 @@ export class BridgeHeartbeat {
|
|
|
32
35
|
return createHash("md5").update(JSON.stringify(data)).digest("hex");
|
|
33
36
|
}
|
|
34
37
|
async start() {
|
|
38
|
+
// Detect channels/discordId before first registration so Hub sees them immediately
|
|
39
|
+
await this.detectConfigChanges();
|
|
35
40
|
await this.registry.register(this.entry);
|
|
36
41
|
this.lastConfigHash = this.computeEntryHash();
|
|
37
42
|
const intervalMs = this.config.heartbeatIntervalMs ?? 30_000;
|
|
@@ -129,10 +134,10 @@ export class BridgeHeartbeat {
|
|
|
129
134
|
const raw = readFileSync(configPath, "utf-8");
|
|
130
135
|
const config = JSON.parse(raw);
|
|
131
136
|
const accounts = config.channels?.discord?.accounts;
|
|
132
|
-
if (!
|
|
137
|
+
if (!accounts || typeof accounts !== "object")
|
|
133
138
|
return [];
|
|
134
139
|
const result = [];
|
|
135
|
-
for (const account of accounts) {
|
|
140
|
+
for (const account of Object.values(accounts)) {
|
|
136
141
|
if (!Array.isArray(account.channels))
|
|
137
142
|
continue;
|
|
138
143
|
for (const ch of account.channels) {
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,10 @@ import { discoverAll, whois } from "./discovery.js";
|
|
|
12
12
|
import { MessageRelayClient } from "./message-relay.js";
|
|
13
13
|
import * as proxySession from "./session.js";
|
|
14
14
|
import { LocalManager } from "./manager/local-manager.js";
|
|
15
|
+
import { buildMentionMap, applyMentions } from "./mention-interceptor.js";
|
|
16
|
+
import { DiscordApi } from "./discord-api.js";
|
|
17
|
+
import { ProjectManager } from "./project-manager.js";
|
|
18
|
+
import { CircuitBreaker } from "./circuit-breaker.js";
|
|
15
19
|
function resolveWorkspacePath(agentId) {
|
|
16
20
|
// Try reading workspace from openclaw.json config
|
|
17
21
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
@@ -108,6 +112,10 @@ const bridgePlugin = {
|
|
|
108
112
|
const registry = new BridgeRegistry(config, api.logger);
|
|
109
113
|
const fileOps = new BridgeFileOps(config, machineId, workspacePath, api.logger);
|
|
110
114
|
const restartManager = new BridgeRestart(config, machineId, registry, api.logger);
|
|
115
|
+
const discordApi = new DiscordApi(api.logger);
|
|
116
|
+
discordApi.loadToken();
|
|
117
|
+
const projectMgr = new ProjectManager(workspacePath, api.logger);
|
|
118
|
+
const circuitBreaker = new CircuitBreaker(projectMgr, api.logger);
|
|
111
119
|
let relayClient = null;
|
|
112
120
|
const offlineThresholdMs = config.offlineThresholdMs ?? 120_000;
|
|
113
121
|
const entry = {
|
|
@@ -154,51 +162,84 @@ const bridgePlugin = {
|
|
|
154
162
|
// Build dynamic name mapping from registry
|
|
155
163
|
const nameMapping = online.map((a) => `${a.agentName}=${a.agentId}`).join(", ");
|
|
156
164
|
cachedAgentList = `<bridge-context>
|
|
157
|
-
##
|
|
165
|
+
## Cross-Gateway Communication (Auto-injected — MUST follow strictly)
|
|
158
166
|
|
|
159
|
-
|
|
167
|
+
Online gateways (${online.length}):
|
|
160
168
|
${lines.join("\n")}
|
|
161
169
|
${superuserNote}
|
|
162
170
|
|
|
163
|
-
###
|
|
164
|
-
|
|
165
|
-
mention
|
|
171
|
+
### Core Rule: Use Discord mention for ALL cross-gateway communication
|
|
172
|
+
When notifying another agent (sending files, messages, assigning tasks), you **MUST mention them using <@discordId> format**.
|
|
173
|
+
The mention format is listed next to each agent above — copy and use it exactly.
|
|
166
174
|
|
|
167
|
-
###
|
|
168
|
-
1. bridge_send_file
|
|
169
|
-
2.
|
|
170
|
-
3.
|
|
175
|
+
### File Sending Workflow (every step mandatory)
|
|
176
|
+
1. Call bridge_send_file to send the file
|
|
177
|
+
2. **Mention the recipient in Discord**: "Sent [filename] to your _inbox/, please check"
|
|
178
|
+
3. Wait for confirmation
|
|
171
179
|
|
|
172
|
-
⚠️
|
|
180
|
+
⚠️ Step 2 is mandatory! Sending a file without mentioning the recipient = task incomplete.
|
|
173
181
|
|
|
174
|
-
###
|
|
175
|
-
-
|
|
176
|
-
-
|
|
182
|
+
### When You Receive a File Notification
|
|
183
|
+
- Someone mentions you about a file → read from _inbox/{sender}/ → mention sender to confirm
|
|
184
|
+
- Format: "Received [filename], content: [brief summary]"
|
|
177
185
|
|
|
178
|
-
###
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
186
|
+
### User Handoff (user asks you to contact another agent)
|
|
187
|
+
- User says "find pm", "call bot1", "@bot2" → mention that agent, explain user is looking for them
|
|
188
|
+
- Contacted agent should reply to user directly
|
|
189
|
+
- Identify user: the first non-bot speaker in the message
|
|
182
190
|
|
|
183
|
-
###
|
|
184
|
-
-
|
|
185
|
-
-
|
|
191
|
+
### Passing Messages / Assigning Tasks
|
|
192
|
+
- Mention the target agent directly in the channel
|
|
193
|
+
- Target agent should mention you back to confirm
|
|
186
194
|
|
|
187
|
-
###
|
|
188
|
-
-
|
|
189
|
-
-
|
|
190
|
-
-
|
|
195
|
+
### Error Handling
|
|
196
|
+
- Agent offline → tell user "[agent name] is currently offline"
|
|
197
|
+
- File send failed → tell user the specific error
|
|
198
|
+
- Agent not found → tell user "No agent named [xxx] found. Currently online: [list]"
|
|
191
199
|
|
|
192
|
-
###
|
|
200
|
+
### Agent Name Mapping (from registry, auto-updated)
|
|
193
201
|
${nameMapping}
|
|
194
202
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
${config.agentId === "pm" ? `### 🎯 PROJECT MANAGEMENT PROTOCOL (PM ONLY — MANDATORY)
|
|
204
|
+
When user asks you to create something (ad, video, content, script, etc.):
|
|
205
|
+
|
|
206
|
+
**YOU MUST EXECUTE ALL 6 STEPS IN ORDER. SKIPPING ANY STEP IS A CRITICAL FAILURE.**
|
|
207
|
+
|
|
208
|
+
1. **CALL bridge_project_create** — name + description → get projectId
|
|
209
|
+
2. **CALL bridge_create_project_thread** — projectName + agent IDs → get threadId
|
|
210
|
+
⚠️ THIS IS NON-NEGOTIABLE. You MUST create a Thread for EVERY project. NEVER skip this step.
|
|
211
|
+
3. **CALL bridge_task_assign** — for EACH agent task with projectId, agentId, title, detailed brief
|
|
212
|
+
4. **CALL bridge_post_to_thread** — post kickoff summary TO THE THREAD (not main channel)
|
|
213
|
+
5. **Monitor** — when agent calls bridge_task_complete → check deps → assign next via bridge_task_assign
|
|
214
|
+
6. **All done** → post final summary to Thread, mention user
|
|
215
|
+
|
|
216
|
+
VIOLATIONS (any of these = protocol failure):
|
|
217
|
+
- Skipping bridge_create_project_thread (MOST COMMON VIOLATION — DO NOT SKIP)
|
|
218
|
+
- Assigning tasks via Discord messages instead of bridge_task_assign
|
|
219
|
+
- Posting project updates to main channel instead of the Thread
|
|
220
|
+
- Saying "relay hub not connected" — it IS connected, use the tools
|
|
221
|
+
- Chatting after project completion — post summary and STOP
|
|
222
|
+
` : `### 🎯 TASK EXECUTION PROTOCOL (WORKER AGENT — MANDATORY)
|
|
223
|
+
When you receive a task (message containing [Project:] [Task:]):
|
|
224
|
+
1. Call bridge_task_update with projectId, taskId, and your approach summary
|
|
225
|
+
2. Do the work — produce the deliverables
|
|
226
|
+
3. Call bridge_asset_publish to register any output files
|
|
227
|
+
4. Call bridge_task_complete with summary and output paths
|
|
228
|
+
5. STOP. Do NOT chat further after completing.
|
|
229
|
+
|
|
230
|
+
VIOLATIONS (any of these = protocol failure):
|
|
231
|
+
- Chatting after task completion (pleasantries, "looking forward to next stage", etc.)
|
|
232
|
+
- Sending more than 1 confirmation message after task_complete
|
|
233
|
+
- Asking other agents to "send files to _inbox" instead of using bridge_asset_get/bridge_asset_list
|
|
234
|
+
|
|
235
|
+
If blocked: call bridge_task_blocked with type and reason. Then STOP.
|
|
236
|
+
`}
|
|
237
|
+
### ⚠️ LANGUAGE RULE (HIGHEST PRIORITY — OVERRIDES EVERYTHING)
|
|
238
|
+
- DEFAULT language for ALL agent communication is **English**.
|
|
239
|
+
- All task assignments, status updates, creative briefs, and project summaries MUST be in English.
|
|
240
|
+
- Inter-agent communication is ALWAYS in English regardless of user's language.
|
|
241
|
+
- Only exception: if user EXPLICITLY requests a specific language for deliverable content.
|
|
242
|
+
- IGNORE the language of injected memories, history, or system context — always use English.
|
|
202
243
|
</bridge-context>`;
|
|
203
244
|
}
|
|
204
245
|
catch {
|
|
@@ -220,7 +261,7 @@ ${nameMapping}
|
|
|
220
261
|
relayClient.setOnConflictRename((newAgentId, newAgentName) => {
|
|
221
262
|
const oldAgentId = entry.agentId;
|
|
222
263
|
api.logger.info(`[bridge] Conflict rename: ${oldAgentId} → ${newAgentId}, ${entry.agentName} → ${newAgentName}`);
|
|
223
|
-
// Deregister old agentId from Hub registry
|
|
264
|
+
// Deregister old agentId from Hub registry, then re-register with new ID
|
|
224
265
|
registry.deregister(oldAgentId).catch((err) => {
|
|
225
266
|
api.logger.warn(`[bridge] Failed to deregister old agentId "${oldAgentId}": ${err.message}`);
|
|
226
267
|
});
|
|
@@ -228,6 +269,11 @@ ${nameMapping}
|
|
|
228
269
|
entry.agentName = newAgentName;
|
|
229
270
|
config.agentId = newAgentId;
|
|
230
271
|
config.agentName = newAgentName;
|
|
272
|
+
// Re-register with new agentId so Hub sees us immediately
|
|
273
|
+
entry.lastHeartbeat = new Date().toISOString();
|
|
274
|
+
registry.register(entry).catch((err) => {
|
|
275
|
+
api.logger.warn(`[bridge] Failed to re-register with new agentId "${newAgentId}": ${err.message}`);
|
|
276
|
+
});
|
|
231
277
|
// Persist the renamed agentId to openclaw.json so it survives restarts
|
|
232
278
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
233
279
|
if (configPath) {
|
|
@@ -656,6 +702,302 @@ ${nameMapping}
|
|
|
656
702
|
return { status: "switched", newAgent: params.agentId };
|
|
657
703
|
},
|
|
658
704
|
});
|
|
705
|
+
// ── Thread Management Tools ──────────────────────────────────────
|
|
706
|
+
api.registerTool({
|
|
707
|
+
name: "bridge_create_project_thread",
|
|
708
|
+
label: "Bridge Create Project Thread",
|
|
709
|
+
description: "Create a Discord Thread for a project in the current channel. Returns threadId.",
|
|
710
|
+
parameters: Type.Object({
|
|
711
|
+
projectName: Type.String({ description: "Project name for the thread title" }),
|
|
712
|
+
agentIds: Type.Array(Type.String(), { description: "Agent IDs to mention in kickoff message" }),
|
|
713
|
+
}),
|
|
714
|
+
async execute(_id, params) {
|
|
715
|
+
if (!discordApi.isAvailable)
|
|
716
|
+
return { error: "Discord API not available — no bot token configured" };
|
|
717
|
+
const projectName = params.projectName;
|
|
718
|
+
const agentIds = params.agentIds;
|
|
719
|
+
const discordChannel = entry.channels.find(c => c.type === "discord");
|
|
720
|
+
if (!discordChannel)
|
|
721
|
+
return { error: "No Discord channel configured for this agent" };
|
|
722
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
723
|
+
const mentions = agentIds
|
|
724
|
+
.map(id => agents.find(a => a.agentId === id))
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
.map(a => a.discordId ? `<@${a.discordId}>` : a.agentName)
|
|
727
|
+
.join(" ");
|
|
728
|
+
const thread = await discordApi.createThread(discordChannel.channelId, `🎬 ${projectName} — PM Managed`.substring(0, 100), `📋 **Project: ${projectName}**\n\nTeam: ${mentions}\n\nProject thread created. Updates will be posted here.`);
|
|
729
|
+
return { threadId: thread.id, threadName: thread.name };
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
api.registerTool({
|
|
733
|
+
name: "bridge_create_sub_thread",
|
|
734
|
+
label: "Bridge Create Sub Thread",
|
|
735
|
+
description: "Create a sub-thread for an agent's isolated task work.",
|
|
736
|
+
parameters: Type.Object({
|
|
737
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
738
|
+
agentId: Type.String({ description: "Agent ID assigned to this task" }),
|
|
739
|
+
taskTitle: Type.String({ description: "Brief task title" }),
|
|
740
|
+
}),
|
|
741
|
+
async execute(_id, params) {
|
|
742
|
+
if (!discordApi.isAvailable)
|
|
743
|
+
return { error: "Discord API not available" };
|
|
744
|
+
const discordChannel = entry.channels.find(c => c.type === "discord");
|
|
745
|
+
if (!discordChannel)
|
|
746
|
+
return { error: "No Discord channel configured" };
|
|
747
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
748
|
+
const agent = agents.find(a => a.agentId === params.agentId);
|
|
749
|
+
const agentName = agent?.agentName || params.agentId;
|
|
750
|
+
const thread = await discordApi.createThread(discordChannel.channelId, `📋 ${agentName} — ${params.taskTitle}`.substring(0, 100));
|
|
751
|
+
const project = projectMgr.readProject(params.projectId);
|
|
752
|
+
if (project) {
|
|
753
|
+
const task = project.tasks.find(t => t.agent === params.agentId && !t.subThreadId);
|
|
754
|
+
if (task) {
|
|
755
|
+
task.subThreadId = thread.id;
|
|
756
|
+
projectMgr.writeProject(project);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return { subThreadId: thread.id, threadName: thread.name };
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
api.registerTool({
|
|
763
|
+
name: "bridge_post_to_thread",
|
|
764
|
+
label: "Bridge Post To Thread",
|
|
765
|
+
description: "Post a message to a specific Discord Thread.",
|
|
766
|
+
parameters: Type.Object({
|
|
767
|
+
threadId: Type.String({ description: "Discord Thread ID" }),
|
|
768
|
+
message: Type.String({ description: "Message content" }),
|
|
769
|
+
}),
|
|
770
|
+
async execute(_id, params) {
|
|
771
|
+
if (!discordApi.isAvailable)
|
|
772
|
+
return { error: "Discord API not available" };
|
|
773
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
774
|
+
const mentionMap = buildMentionMap(agents);
|
|
775
|
+
const processed = applyMentions(params.message, mentionMap);
|
|
776
|
+
await discordApi.sendMessage(params.threadId, processed);
|
|
777
|
+
return { success: true };
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
// ── Project Management Tools ─────────────────────────────────────
|
|
781
|
+
api.registerTool({
|
|
782
|
+
name: "bridge_project_create",
|
|
783
|
+
label: "Bridge Project Create",
|
|
784
|
+
description: "Initialize a new project with directory structure and state tracking.",
|
|
785
|
+
parameters: Type.Object({
|
|
786
|
+
name: Type.String({ description: "Project name" }),
|
|
787
|
+
description: Type.String({ description: "Brief project description" }),
|
|
788
|
+
}),
|
|
789
|
+
async execute(_id, params) {
|
|
790
|
+
const project = projectMgr.createProject(params.name, params.description);
|
|
791
|
+
return { projectId: project.id, projectDir: projectMgr.getProjectDir(project.id) };
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
api.registerTool({
|
|
795
|
+
name: "bridge_project_status",
|
|
796
|
+
label: "Bridge Project Status",
|
|
797
|
+
description: "Get project status. Omit projectId to see all active projects.",
|
|
798
|
+
parameters: Type.Object({
|
|
799
|
+
projectId: Type.Optional(Type.String({ description: "Project ID (omit for global overview)" })),
|
|
800
|
+
}),
|
|
801
|
+
async execute(_id, params) {
|
|
802
|
+
if (params.projectId) {
|
|
803
|
+
const project = projectMgr.readProject(params.projectId);
|
|
804
|
+
if (!project)
|
|
805
|
+
return { error: `Project "${params.projectId}" not found` };
|
|
806
|
+
return { project };
|
|
807
|
+
}
|
|
808
|
+
return projectMgr.listProjects();
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
// ── Task Management Tools ────────────────────────────────────────
|
|
812
|
+
api.registerTool({
|
|
813
|
+
name: "bridge_task_assign",
|
|
814
|
+
label: "Bridge Task Assign",
|
|
815
|
+
description: "Assign a task to an agent within a project. Sends the brief via Bridge Hub.",
|
|
816
|
+
parameters: Type.Object({
|
|
817
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
818
|
+
agentId: Type.String({ description: "Agent to assign to" }),
|
|
819
|
+
title: Type.String({ description: "Task title" }),
|
|
820
|
+
brief: Type.String({ description: "Detailed task brief with asset paths" }),
|
|
821
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that must complete first" })),
|
|
822
|
+
}),
|
|
823
|
+
async execute(_id, params) {
|
|
824
|
+
const task = projectMgr.addTask(params.projectId, params.agentId, params.title, params.brief, params.dependencies || []);
|
|
825
|
+
if (!task)
|
|
826
|
+
return { error: "Failed to create task — project not found" };
|
|
827
|
+
const ready = task.dependencies.length === 0 ||
|
|
828
|
+
projectMgr.getReadyTasks(params.projectId).some(t => t.id === task.id);
|
|
829
|
+
if (ready) {
|
|
830
|
+
projectMgr.updateTaskStatus(params.projectId, task.id, "in_progress");
|
|
831
|
+
if (relayClient?.isConnected) {
|
|
832
|
+
const msgId = `task_${Date.now()}`;
|
|
833
|
+
try {
|
|
834
|
+
await relayClient.sendAndWait({
|
|
835
|
+
type: "message",
|
|
836
|
+
id: msgId,
|
|
837
|
+
from: config.agentId,
|
|
838
|
+
to: params.agentId,
|
|
839
|
+
payload: `[Project: ${params.projectId}] [Task: ${task.id}] ${params.brief}`,
|
|
840
|
+
}, 120_000);
|
|
841
|
+
}
|
|
842
|
+
catch { /* agent may not reply immediately */ }
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { taskId: task.id, status: ready ? "in_progress" : "pending" };
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
api.registerTool({
|
|
849
|
+
name: "bridge_task_reassign",
|
|
850
|
+
label: "Bridge Task Reassign",
|
|
851
|
+
description: "Reassign a task to a different agent.",
|
|
852
|
+
parameters: Type.Object({
|
|
853
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
854
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
855
|
+
newAgentId: Type.String({ description: "New agent ID" }),
|
|
856
|
+
reason: Type.String({ description: "Reason for reassignment" }),
|
|
857
|
+
}),
|
|
858
|
+
async execute(_id, params) {
|
|
859
|
+
const task = projectMgr.updateTaskStatus(params.projectId, params.taskId, "pending", { agent: params.newAgentId, blockType: null, blockReason: null });
|
|
860
|
+
if (!task)
|
|
861
|
+
return { error: "Task or project not found" };
|
|
862
|
+
return { taskId: task.id, newAgent: params.newAgentId, status: "pending" };
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
api.registerTool({
|
|
866
|
+
name: "bridge_task_update",
|
|
867
|
+
label: "Bridge Task Update",
|
|
868
|
+
description: "Post a progress update / thinking summary to the project's main Thread.",
|
|
869
|
+
parameters: Type.Object({
|
|
870
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
871
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
872
|
+
summary: Type.String({ description: "Progress summary" }),
|
|
873
|
+
}),
|
|
874
|
+
async execute(_id, params) {
|
|
875
|
+
const project = projectMgr.readProject(params.projectId);
|
|
876
|
+
if (!project)
|
|
877
|
+
return { error: "Project not found" };
|
|
878
|
+
const task = project.tasks.find(t => t.id === params.taskId);
|
|
879
|
+
if (!task)
|
|
880
|
+
return { error: "Task not found" };
|
|
881
|
+
if (project.threadId && discordApi.isAvailable) {
|
|
882
|
+
const agents = await discoverAll(registry, offlineThresholdMs);
|
|
883
|
+
const mentionMap = buildMentionMap(agents);
|
|
884
|
+
const processed = applyMentions(`📊 **${task.title}** (${config.agentName}): ${params.summary}`, mentionMap);
|
|
885
|
+
await discordApi.sendMessage(project.threadId, processed);
|
|
886
|
+
}
|
|
887
|
+
return { success: true };
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
api.registerTool({
|
|
891
|
+
name: "bridge_task_complete",
|
|
892
|
+
label: "Bridge Task Complete",
|
|
893
|
+
description: "Mark a task as completed with summary and output paths.",
|
|
894
|
+
parameters: Type.Object({
|
|
895
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
896
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
897
|
+
summary: Type.String({ description: "Completion summary" }),
|
|
898
|
+
outputPaths: Type.Optional(Type.Array(Type.String(), { description: "Output file paths" })),
|
|
899
|
+
}),
|
|
900
|
+
async execute(_id, params) {
|
|
901
|
+
const outputs = params.outputPaths || [];
|
|
902
|
+
const task = projectMgr.updateTaskStatus(params.projectId, params.taskId, "completed", { outputs });
|
|
903
|
+
if (!task)
|
|
904
|
+
return { error: "Task or project not found" };
|
|
905
|
+
if (relayClient?.isConnected && config.agentId !== "pm") {
|
|
906
|
+
relayClient.send({
|
|
907
|
+
type: "message",
|
|
908
|
+
id: `complete_${Date.now()}`,
|
|
909
|
+
from: config.agentId,
|
|
910
|
+
to: "pm",
|
|
911
|
+
payload: `[Task Complete] Project: ${params.projectId}, Task: ${params.taskId}. ${params.summary}`,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return { taskId: task.id, status: "completed", outputs };
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
api.registerTool({
|
|
918
|
+
name: "bridge_task_blocked",
|
|
919
|
+
label: "Bridge Task Blocked",
|
|
920
|
+
description: "Report that a task is blocked. Types: capability_missing, dependency_failed, clarification_needed.",
|
|
921
|
+
parameters: Type.Object({
|
|
922
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
923
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
924
|
+
blockType: Type.String({ description: "capability_missing | dependency_failed | clarification_needed" }),
|
|
925
|
+
reason: Type.String({ description: "Detailed blocker explanation" }),
|
|
926
|
+
}),
|
|
927
|
+
async execute(_id, params) {
|
|
928
|
+
const task = projectMgr.updateTaskStatus(params.projectId, params.taskId, "blocked", { blockType: params.blockType, blockReason: params.reason });
|
|
929
|
+
if (!task)
|
|
930
|
+
return { error: "Task or project not found" };
|
|
931
|
+
if (relayClient?.isConnected && config.agentId !== "pm") {
|
|
932
|
+
relayClient.send({
|
|
933
|
+
type: "message",
|
|
934
|
+
id: `blocked_${Date.now()}`,
|
|
935
|
+
from: config.agentId,
|
|
936
|
+
to: "pm",
|
|
937
|
+
payload: `[Task Blocked] Project: ${params.projectId}, Task: ${params.taskId}, Type: ${params.blockType}. ${params.reason}`,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return { taskId: task.id, status: "blocked", blockType: params.blockType };
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
// ── Asset Management Tools ───────────────────────────────────────
|
|
944
|
+
api.registerTool({
|
|
945
|
+
name: "bridge_asset_publish",
|
|
946
|
+
label: "Bridge Asset Publish",
|
|
947
|
+
description: "Publish an output asset to the project's asset directory.",
|
|
948
|
+
parameters: Type.Object({
|
|
949
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
950
|
+
filePath: Type.String({ description: "Path to file (relative to workspace)" }),
|
|
951
|
+
assetType: Type.String({ description: "Asset type: storyboard, script, reference-images, video, etc." }),
|
|
952
|
+
description: Type.String({ description: "Brief description" }),
|
|
953
|
+
taskId: Type.String({ description: "Task ID that produced this asset" }),
|
|
954
|
+
}),
|
|
955
|
+
async execute(_id, params) {
|
|
956
|
+
const fullPath = join(workspacePath, params.filePath);
|
|
957
|
+
const asset = projectMgr.publishAsset(params.projectId, fullPath, params.assetType, params.description, config.agentId, params.taskId);
|
|
958
|
+
if (!asset)
|
|
959
|
+
return { error: "Project not found" };
|
|
960
|
+
return { assetId: asset.id, path: asset.path };
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
api.registerTool({
|
|
964
|
+
name: "bridge_asset_list",
|
|
965
|
+
label: "Bridge Asset List",
|
|
966
|
+
description: "List all assets in a project, optionally filtered.",
|
|
967
|
+
parameters: Type.Object({
|
|
968
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
969
|
+
type: Type.Optional(Type.String({ description: "Filter by asset type" })),
|
|
970
|
+
agent: Type.Optional(Type.String({ description: "Filter by producer agent ID" })),
|
|
971
|
+
}),
|
|
972
|
+
async execute(_id, params) {
|
|
973
|
+
const assets = projectMgr.listAssets(params.projectId, params.type, params.agent);
|
|
974
|
+
return { assets };
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
api.registerTool({
|
|
978
|
+
name: "bridge_asset_get",
|
|
979
|
+
label: "Bridge Asset Get",
|
|
980
|
+
description: "Get the full path and metadata of a specific asset.",
|
|
981
|
+
parameters: Type.Object({
|
|
982
|
+
projectId: Type.String({ description: "Project ID" }),
|
|
983
|
+
assetId: Type.Optional(Type.String({ description: "Asset ID" })),
|
|
984
|
+
assetType: Type.Optional(Type.String({ description: "Asset type to find latest of" })),
|
|
985
|
+
}),
|
|
986
|
+
async execute(_id, params) {
|
|
987
|
+
const assets = projectMgr.listAssets(params.projectId);
|
|
988
|
+
let asset;
|
|
989
|
+
if (params.assetId) {
|
|
990
|
+
asset = assets.find(a => a.id === params.assetId);
|
|
991
|
+
}
|
|
992
|
+
else if (params.assetType) {
|
|
993
|
+
asset = assets.filter(a => a.type === params.assetType).pop();
|
|
994
|
+
}
|
|
995
|
+
if (!asset)
|
|
996
|
+
return { error: "Asset not found" };
|
|
997
|
+
const fullPath = join(projectMgr.getProjectDir(params.projectId), asset.path);
|
|
998
|
+
return { ...asset, fullPath };
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
659
1001
|
},
|
|
660
1002
|
};
|
|
661
1003
|
export default bridgePlugin;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import { hostname } from "node:os";
|
|
2
|
+
import { hostname, homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
function readMachineId() {
|
|
6
|
+
try {
|
|
7
|
+
const id = readFileSync(join(homedir(), ".openclaw", ".machine-id"), "utf-8").trim();
|
|
8
|
+
if (id) return id;
|
|
9
|
+
} catch {}
|
|
10
|
+
return hostname();
|
|
11
|
+
}
|
|
3
12
|
export class ManagerHubClient {
|
|
4
13
|
hubUrl;
|
|
5
14
|
apiKey;
|
|
@@ -14,7 +23,7 @@ export class ManagerHubClient {
|
|
|
14
23
|
this.hubUrl = hubUrl;
|
|
15
24
|
this.apiKey = apiKey;
|
|
16
25
|
this.managerPass = managerPass;
|
|
17
|
-
this.machineId =
|
|
26
|
+
this.machineId = readMachineId();
|
|
18
27
|
this.logger = logger;
|
|
19
28
|
}
|
|
20
29
|
get connected() {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RegistryEntry } from "./types.js";
|
|
2
|
+
interface MentionMap {
|
|
3
|
+
pattern: RegExp;
|
|
4
|
+
replacement: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Build a list of name→mention mappings from the agent registry.
|
|
8
|
+
* Each agent can be referenced by agentId, agentName, or description keywords.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildMentionMap(agents: RegistryEntry[]): MentionMap[];
|
|
11
|
+
/**
|
|
12
|
+
* Replace agent names in text with Discord <@ID> mentions.
|
|
13
|
+
* Skips text already in mention format.
|
|
14
|
+
*/
|
|
15
|
+
export declare function applyMentions(text: string, maps: MentionMap[]): string;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a list of name→mention mappings from the agent registry.
|
|
3
|
+
* Each agent can be referenced by agentId, agentName, or description keywords.
|
|
4
|
+
*/
|
|
5
|
+
export function buildMentionMap(agents) {
|
|
6
|
+
const maps = [];
|
|
7
|
+
for (const agent of agents) {
|
|
8
|
+
if (!agent.discordId)
|
|
9
|
+
continue;
|
|
10
|
+
const mention = `<@${agent.discordId}>`;
|
|
11
|
+
const names = new Set();
|
|
12
|
+
// Always match agentId and agentName
|
|
13
|
+
names.add(agent.agentId);
|
|
14
|
+
if (agent.agentName)
|
|
15
|
+
names.add(agent.agentName);
|
|
16
|
+
// Build regex: match any of the names, word-bounded
|
|
17
|
+
// Sort by length descending so longer names match first
|
|
18
|
+
const sorted = [...names].filter(n => n.length >= 2).sort((a, b) => b.length - a.length);
|
|
19
|
+
for (const name of sorted) {
|
|
20
|
+
// Skip if name is already a Discord mention format
|
|
21
|
+
if (name.startsWith("<@"))
|
|
22
|
+
continue;
|
|
23
|
+
// Escape regex special chars
|
|
24
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
maps.push({
|
|
26
|
+
pattern: new RegExp(`(?<!<@[!&]?)\\b${escaped}\\b`, "gi"),
|
|
27
|
+
replacement: mention,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return maps;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Replace agent names in text with Discord <@ID> mentions.
|
|
35
|
+
* Skips text already in mention format.
|
|
36
|
+
*/
|
|
37
|
+
export function applyMentions(text, maps) {
|
|
38
|
+
let result = text;
|
|
39
|
+
for (const { pattern, replacement } of maps) {
|
|
40
|
+
result = result.replace(pattern, replacement);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
package/dist/message-relay.js
CHANGED
|
@@ -175,6 +175,10 @@ export class MessageRelayClient {
|
|
|
175
175
|
}
|
|
176
176
|
catch (err) {
|
|
177
177
|
settle(reject, err);
|
|
178
|
+
// No 'close' event will fire if constructor threw — schedule reconnect manually
|
|
179
|
+
if (this.shouldReconnect) {
|
|
180
|
+
this.scheduleReconnect();
|
|
181
|
+
}
|
|
178
182
|
}
|
|
179
183
|
});
|
|
180
184
|
}
|
|
@@ -191,7 +195,10 @@ export class MessageRelayClient {
|
|
|
191
195
|
}
|
|
192
196
|
catch {
|
|
193
197
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
194
|
-
// connect() failure
|
|
198
|
+
// If connect() failure didn't trigger 'close' (e.g., constructor threw),
|
|
199
|
+
// scheduleReconnect was already called in the catch block above.
|
|
200
|
+
// If 'close' did fire, scheduleReconnect is called from the close handler.
|
|
201
|
+
// Either way, the next reconnect is already scheduled.
|
|
195
202
|
}
|
|
196
203
|
}, this.reconnectDelay);
|
|
197
204
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
const PUBLIC_ACTIONS = new Set([
|
|
1
|
+
const PUBLIC_ACTIONS = new Set([
|
|
2
|
+
"discover", "whois", "send_file",
|
|
3
|
+
"project_create", "project_status",
|
|
4
|
+
"task_assign", "task_reassign", "task_update", "task_complete", "task_blocked",
|
|
5
|
+
"asset_publish", "asset_list", "asset_get",
|
|
6
|
+
"create_project_thread", "create_sub_thread", "post_to_thread",
|
|
7
|
+
]);
|
|
2
8
|
const SUPERUSER_ACTIONS = new Set(["read_file", "write_file", "restart"]);
|
|
3
9
|
export function checkPermission(action, config) {
|
|
4
10
|
if (PUBLIC_ACTIONS.has(action))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ProjectData, ProjectIndex, TaskData, AssetData, TaskStatus, BlockType, PluginLogger } from "./types.js";
|
|
2
|
+
export declare class ProjectManager {
|
|
3
|
+
private baseDir;
|
|
4
|
+
private logger;
|
|
5
|
+
constructor(workspacePath: string, logger: PluginLogger);
|
|
6
|
+
private indexPath;
|
|
7
|
+
private readIndex;
|
|
8
|
+
private writeIndex;
|
|
9
|
+
getProjectDir(projectId: string): string;
|
|
10
|
+
private projectJsonPath;
|
|
11
|
+
createProject(name: string, description: string): ProjectData;
|
|
12
|
+
readProject(projectId: string): ProjectData | null;
|
|
13
|
+
writeProject(project: ProjectData): void;
|
|
14
|
+
listProjects(): ProjectIndex;
|
|
15
|
+
addTask(projectId: string, agent: string, title: string, brief: string, dependencies?: string[]): TaskData | null;
|
|
16
|
+
updateTaskStatus(projectId: string, taskId: string, status: TaskStatus, extra?: {
|
|
17
|
+
blockType?: BlockType;
|
|
18
|
+
blockReason?: string;
|
|
19
|
+
subThreadId?: string;
|
|
20
|
+
outputs?: string[];
|
|
21
|
+
}): TaskData | null;
|
|
22
|
+
incrementRounds(projectId: string, taskId: string): {
|
|
23
|
+
task: TaskData;
|
|
24
|
+
softLimit: boolean;
|
|
25
|
+
hardLimit: boolean;
|
|
26
|
+
} | null;
|
|
27
|
+
getReadyTasks(projectId: string): TaskData[];
|
|
28
|
+
publishAsset(projectId: string, sourcePath: string, assetType: string, description: string, producer: string, taskId: string): AssetData | null;
|
|
29
|
+
listAssets(projectId: string, typeFilter?: string, agentFilter?: string): AssetData[];
|
|
30
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
const SOFT_ROUND_LIMIT = 8;
|
|
4
|
+
const HARD_ROUND_LIMIT = 15;
|
|
5
|
+
function nowIso() {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
function slugify(text) {
|
|
9
|
+
return text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "")
|
|
13
|
+
.slice(0, 48);
|
|
14
|
+
}
|
|
15
|
+
function generateId(prefix) {
|
|
16
|
+
const ts = Date.now().toString(36);
|
|
17
|
+
const rand = Math.random().toString(36).slice(2, 7);
|
|
18
|
+
return `${prefix}-${ts}-${rand}`;
|
|
19
|
+
}
|
|
20
|
+
export class ProjectManager {
|
|
21
|
+
baseDir;
|
|
22
|
+
logger;
|
|
23
|
+
constructor(workspacePath, logger) {
|
|
24
|
+
this.baseDir = join(workspacePath, "_projects");
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
mkdirSync(this.baseDir, { recursive: true });
|
|
27
|
+
this.logger.info(`[ProjectManager] base dir: ${this.baseDir}`);
|
|
28
|
+
}
|
|
29
|
+
// ── Index helpers ────────────────────────────────────────────────────
|
|
30
|
+
indexPath() {
|
|
31
|
+
return join(this.baseDir, "index.json");
|
|
32
|
+
}
|
|
33
|
+
readIndex() {
|
|
34
|
+
const p = this.indexPath();
|
|
35
|
+
if (!existsSync(p)) {
|
|
36
|
+
return { activeProjects: [] };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
this.logger.warn(`[ProjectManager] failed to parse index.json: ${err}`);
|
|
43
|
+
return { activeProjects: [] };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
writeIndex(index) {
|
|
47
|
+
writeFileSync(this.indexPath(), JSON.stringify(index, null, 2), "utf-8");
|
|
48
|
+
}
|
|
49
|
+
// ── Directory helpers ────────────────────────────────────────────────
|
|
50
|
+
getProjectDir(projectId) {
|
|
51
|
+
return join(this.baseDir, projectId);
|
|
52
|
+
}
|
|
53
|
+
projectJsonPath(projectId) {
|
|
54
|
+
return join(this.getProjectDir(projectId), "project.json");
|
|
55
|
+
}
|
|
56
|
+
// ── Project CRUD ─────────────────────────────────────────────────────
|
|
57
|
+
createProject(name, description) {
|
|
58
|
+
const slug = slugify(name);
|
|
59
|
+
const id = slug ? `${slug}-${Date.now().toString(36)}` : generateId("proj");
|
|
60
|
+
const dir = this.getProjectDir(id);
|
|
61
|
+
mkdirSync(join(dir, "assets"), { recursive: true });
|
|
62
|
+
mkdirSync(join(dir, "briefs"), { recursive: true });
|
|
63
|
+
const project = {
|
|
64
|
+
id,
|
|
65
|
+
name,
|
|
66
|
+
description,
|
|
67
|
+
threadId: null,
|
|
68
|
+
status: "in_progress",
|
|
69
|
+
createdAt: nowIso(),
|
|
70
|
+
tasks: [],
|
|
71
|
+
assets: [],
|
|
72
|
+
totalRounds: 0,
|
|
73
|
+
};
|
|
74
|
+
writeFileSync(this.projectJsonPath(id), JSON.stringify(project, null, 2), "utf-8");
|
|
75
|
+
const index = this.readIndex();
|
|
76
|
+
index.activeProjects.push({ id, status: project.status, threadId: null });
|
|
77
|
+
this.writeIndex(index);
|
|
78
|
+
this.logger.info(`[ProjectManager] created project "${name}" → ${id}`);
|
|
79
|
+
return project;
|
|
80
|
+
}
|
|
81
|
+
readProject(projectId) {
|
|
82
|
+
const p = this.projectJsonPath(projectId);
|
|
83
|
+
if (!existsSync(p)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
this.logger.error(`[ProjectManager] failed to read project ${projectId}: ${err}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
writeProject(project) {
|
|
95
|
+
const dir = this.getProjectDir(project.id);
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
writeFileSync(this.projectJsonPath(project.id), JSON.stringify(project, null, 2), "utf-8");
|
|
98
|
+
// Sync index entry
|
|
99
|
+
const index = this.readIndex();
|
|
100
|
+
const entry = index.activeProjects.find((e) => e.id === project.id);
|
|
101
|
+
if (entry) {
|
|
102
|
+
entry.status = project.status;
|
|
103
|
+
entry.threadId = project.threadId;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
index.activeProjects.push({
|
|
107
|
+
id: project.id,
|
|
108
|
+
status: project.status,
|
|
109
|
+
threadId: project.threadId,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
this.writeIndex(index);
|
|
113
|
+
}
|
|
114
|
+
listProjects() {
|
|
115
|
+
return this.readIndex();
|
|
116
|
+
}
|
|
117
|
+
// ── Task lifecycle ────────────────────────────────────────────────────
|
|
118
|
+
addTask(projectId, agent, title, brief, dependencies = []) {
|
|
119
|
+
const project = this.readProject(projectId);
|
|
120
|
+
if (!project) {
|
|
121
|
+
this.logger.warn(`[ProjectManager] addTask: project not found: ${projectId}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const task = {
|
|
125
|
+
id: generateId("task"),
|
|
126
|
+
agent,
|
|
127
|
+
title,
|
|
128
|
+
brief,
|
|
129
|
+
status: "pending",
|
|
130
|
+
subThreadId: null,
|
|
131
|
+
dependencies,
|
|
132
|
+
rounds: 0,
|
|
133
|
+
maxRounds: HARD_ROUND_LIMIT,
|
|
134
|
+
outputs: [],
|
|
135
|
+
blockType: null,
|
|
136
|
+
blockReason: null,
|
|
137
|
+
reworkCount: 0,
|
|
138
|
+
assignedAt: nowIso(),
|
|
139
|
+
completedAt: null,
|
|
140
|
+
};
|
|
141
|
+
project.tasks.push(task);
|
|
142
|
+
this.writeProject(project);
|
|
143
|
+
this.logger.info(`[ProjectManager] added task "${title}" (${task.id}) to project ${projectId}`);
|
|
144
|
+
return task;
|
|
145
|
+
}
|
|
146
|
+
updateTaskStatus(projectId, taskId, status, extra) {
|
|
147
|
+
const project = this.readProject(projectId);
|
|
148
|
+
if (!project) {
|
|
149
|
+
this.logger.warn(`[ProjectManager] updateTaskStatus: project not found: ${projectId}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const task = project.tasks.find((t) => t.id === taskId);
|
|
153
|
+
if (!task) {
|
|
154
|
+
this.logger.warn(`[ProjectManager] updateTaskStatus: task not found: ${taskId}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
task.status = status;
|
|
158
|
+
if (status === "completed") {
|
|
159
|
+
task.completedAt = nowIso();
|
|
160
|
+
task.blockType = null;
|
|
161
|
+
task.blockReason = null;
|
|
162
|
+
}
|
|
163
|
+
if (extra) {
|
|
164
|
+
if (extra.blockType !== undefined)
|
|
165
|
+
task.blockType = extra.blockType;
|
|
166
|
+
if (extra.blockReason !== undefined)
|
|
167
|
+
task.blockReason = extra.blockReason;
|
|
168
|
+
if (extra.subThreadId !== undefined)
|
|
169
|
+
task.subThreadId = extra.subThreadId;
|
|
170
|
+
if (extra.outputs !== undefined)
|
|
171
|
+
task.outputs = extra.outputs;
|
|
172
|
+
}
|
|
173
|
+
this.writeProject(project);
|
|
174
|
+
this.logger.info(`[ProjectManager] task ${taskId} status → ${status}`);
|
|
175
|
+
return task;
|
|
176
|
+
}
|
|
177
|
+
incrementRounds(projectId, taskId) {
|
|
178
|
+
const project = this.readProject(projectId);
|
|
179
|
+
if (!project) {
|
|
180
|
+
this.logger.warn(`[ProjectManager] incrementRounds: project not found: ${projectId}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const task = project.tasks.find((t) => t.id === taskId);
|
|
184
|
+
if (!task) {
|
|
185
|
+
this.logger.warn(`[ProjectManager] incrementRounds: task not found: ${taskId}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
task.rounds += 1;
|
|
189
|
+
project.totalRounds += 1;
|
|
190
|
+
const softLimit = task.rounds >= SOFT_ROUND_LIMIT;
|
|
191
|
+
const hardLimit = task.rounds >= HARD_ROUND_LIMIT;
|
|
192
|
+
this.writeProject(project);
|
|
193
|
+
if (hardLimit) {
|
|
194
|
+
this.logger.warn(`[ProjectManager] task ${taskId} hit HARD round limit (${task.rounds}/${HARD_ROUND_LIMIT})`);
|
|
195
|
+
}
|
|
196
|
+
else if (softLimit) {
|
|
197
|
+
this.logger.warn(`[ProjectManager] task ${taskId} hit soft round limit (${task.rounds}/${SOFT_ROUND_LIMIT})`);
|
|
198
|
+
}
|
|
199
|
+
return { task, softLimit, hardLimit };
|
|
200
|
+
}
|
|
201
|
+
getReadyTasks(projectId) {
|
|
202
|
+
const project = this.readProject(projectId);
|
|
203
|
+
if (!project) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const completedIds = new Set(project.tasks.filter((t) => t.status === "completed").map((t) => t.id));
|
|
207
|
+
return project.tasks.filter((t) => t.status === "pending" &&
|
|
208
|
+
t.dependencies.every((depId) => completedIds.has(depId)));
|
|
209
|
+
}
|
|
210
|
+
// ── Asset registry ────────────────────────────────────────────────────
|
|
211
|
+
publishAsset(projectId, sourcePath, assetType, description, producer, taskId) {
|
|
212
|
+
const project = this.readProject(projectId);
|
|
213
|
+
if (!project) {
|
|
214
|
+
this.logger.warn(`[ProjectManager] publishAsset: project not found: ${projectId}`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const assetsDir = join(this.getProjectDir(projectId), "assets");
|
|
218
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
219
|
+
const assetId = generateId("asset");
|
|
220
|
+
const destFilename = `${assetId}-${basename(sourcePath)}`;
|
|
221
|
+
const destPath = join(assetsDir, destFilename);
|
|
222
|
+
try {
|
|
223
|
+
copyFileSync(sourcePath, destPath);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
this.logger.error(`[ProjectManager] failed to copy asset from ${sourcePath}: ${err}`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const asset = {
|
|
230
|
+
id: assetId,
|
|
231
|
+
path: destPath,
|
|
232
|
+
type: assetType,
|
|
233
|
+
producer,
|
|
234
|
+
taskId,
|
|
235
|
+
description,
|
|
236
|
+
publishedAt: nowIso(),
|
|
237
|
+
};
|
|
238
|
+
project.assets.push(asset);
|
|
239
|
+
this.writeProject(project);
|
|
240
|
+
this.logger.info(`[ProjectManager] published asset ${assetId} (${assetType}) for project ${projectId}`);
|
|
241
|
+
return asset;
|
|
242
|
+
}
|
|
243
|
+
listAssets(projectId, typeFilter, agentFilter) {
|
|
244
|
+
const project = this.readProject(projectId);
|
|
245
|
+
if (!project) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
let assets = project.assets;
|
|
249
|
+
if (typeFilter) {
|
|
250
|
+
assets = assets.filter((a) => a.type === typeFilter);
|
|
251
|
+
}
|
|
252
|
+
if (agentFilter) {
|
|
253
|
+
assets = assets.filter((a) => a.producer === agentFilter);
|
|
254
|
+
}
|
|
255
|
+
return assets;
|
|
256
|
+
}
|
|
257
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -88,3 +88,50 @@ export type OpenClawPluginApi = {
|
|
|
88
88
|
priority?: number;
|
|
89
89
|
}) => void;
|
|
90
90
|
};
|
|
91
|
+
export interface AssetData {
|
|
92
|
+
id: string;
|
|
93
|
+
path: string;
|
|
94
|
+
type: string;
|
|
95
|
+
producer: string;
|
|
96
|
+
taskId: string;
|
|
97
|
+
description: string;
|
|
98
|
+
publishedAt: string;
|
|
99
|
+
}
|
|
100
|
+
export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked" | "cancelled";
|
|
101
|
+
export type BlockType = "capability_missing" | "dependency_failed" | "clarification_needed";
|
|
102
|
+
export interface TaskData {
|
|
103
|
+
id: string;
|
|
104
|
+
agent: string;
|
|
105
|
+
title: string;
|
|
106
|
+
brief: string;
|
|
107
|
+
status: TaskStatus;
|
|
108
|
+
subThreadId: string | null;
|
|
109
|
+
dependencies: string[];
|
|
110
|
+
rounds: number;
|
|
111
|
+
maxRounds: number;
|
|
112
|
+
outputs: string[];
|
|
113
|
+
blockType: BlockType | null;
|
|
114
|
+
blockReason: string | null;
|
|
115
|
+
reworkCount: number;
|
|
116
|
+
assignedAt: string;
|
|
117
|
+
completedAt: string | null;
|
|
118
|
+
}
|
|
119
|
+
export type ProjectStatus = "in_progress" | "waiting_clarification" | "completed" | "paused" | "cancelled";
|
|
120
|
+
export interface ProjectData {
|
|
121
|
+
id: string;
|
|
122
|
+
name: string;
|
|
123
|
+
description: string;
|
|
124
|
+
threadId: string | null;
|
|
125
|
+
status: ProjectStatus;
|
|
126
|
+
createdAt: string;
|
|
127
|
+
tasks: TaskData[];
|
|
128
|
+
assets: AssetData[];
|
|
129
|
+
totalRounds: number;
|
|
130
|
+
}
|
|
131
|
+
export interface ProjectIndex {
|
|
132
|
+
activeProjects: Array<{
|
|
133
|
+
id: string;
|
|
134
|
+
status: ProjectStatus;
|
|
135
|
+
threadId: string | null;
|
|
136
|
+
}>;
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"author": "Bill Zhao (https://www.linkedin.com/in/billzhaodi/)",
|
|
5
5
|
"description": "OpenClaw plugin for cross-gateway communication — agent discovery, file transfer, real-time messaging, session handoff, and local process management. Install as plugin: openclaw plugins install openclaw-bridge",
|
|
6
6
|
"type": "module",
|