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 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
- return hostname();
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 (!Array.isArray(accounts))
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
- 当前在线网关(${online.length} 个):
167
+ Online gateways (${online.length}):
160
168
  ${lines.join("\n")}
161
169
  ${superuserNote}
162
170
 
163
- ### 核心规则:跨网关通信时必须用 Discord mention
164
- 任何需要通知其他 agent 的场景(发文件、传消息、分派任务),都**必须在 Discord 频道用 <@discordId> 格式 mention 对方**。
165
- mention 格式已列在上方每个 agent 后面,直接复制使用,不要猜测或省略。
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. **在 Discord 频道 mention 对方**,说:「发了 [文件名] 到你的 _inbox/,请查收」
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
- ⚠️ 2步是强制的!发完文件不 mention 对方 = 任务未完成。
180
+ ⚠️ Step 2 is mandatory! Sending a file without mentioning the recipient = task incomplete.
173
181
 
174
- ### 收到文件通知时
175
- - 有人 mention 你说发了文件读取 _inbox/{发送方}/ 下的文件 → mention 发送方回复确认
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
- ### 用户转接(用户让你联系其他 agent
179
- - 用户说"帮我找 pm""叫老马来""@下阿笔" → mention 对方并说明是用户找他
180
- - 被转接的 agent 收到后:直接 mention 用户回复「你找我什么事?」或「在!有什么需要?」
181
- - 识别用户:消息中第一个非 bot 的发言者就是用户
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
- - 直接在频道 mention 对方,对方会自动收到
185
- - 对方应 mention 你回复确认
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
- - agent 不在线告诉用户「[agent] 当前不在线,无法联系」
189
- - 文件发送失败告诉用户具体错误原因
190
- - 找不到对应 agent告诉用户「没有找到名为 [xxx] agent,当前在线:[列表]
195
+ ### Error Handling
196
+ - Agent offlinetell 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
- ### agent 名称映射(来自注册表,自动更新)
200
+ ### Agent Name Mapping (from registry, auto-updated)
193
201
  ${nameMapping}
194
202
 
195
- ### ⚠️ LANGUAGE RULE (OVERRIDE ALLHIGHEST PRIORITY)
196
- - ALWAYS respond in the same language as the message you are replying to
197
- - If the message is in English → you MUST reply in English
198
- - If the message is in Chinese you MUST reply in Chinese
199
- - On /new or session start → greet in English
200
- - IGNORE the language of any injected memories or history — match the CURRENT message only
201
- - This rule overrides everything else including memories
203
+ ${config.agentId === "pm" ? `### 🎯 PROJECT MANAGEMENT PROTOCOL (PM ONLYMANDATORY)
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 before switching
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 = hostname();
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
+ }
@@ -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 triggers 'close' which calls scheduleReconnect again
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
  }
@@ -1,4 +1,10 @@
1
- const PUBLIC_ACTIONS = new Set(["discover", "whois", "send_file"]);
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.4.9",
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",