openclaw-bridge 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +43 -16
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +809 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/config.js +64 -0
  6. package/dist/discovery.d.ts +4 -0
  7. package/dist/discovery.js +6 -0
  8. package/dist/file-ops.d.ts +22 -0
  9. package/dist/file-ops.js +253 -0
  10. package/dist/heartbeat.d.ts +21 -0
  11. package/dist/heartbeat.js +152 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +624 -0
  14. package/dist/manager/hub-client.d.ts +18 -0
  15. package/dist/manager/hub-client.js +89 -0
  16. package/dist/manager/local-manager.d.ts +12 -0
  17. package/dist/manager/local-manager.js +117 -0
  18. package/dist/manager/pm2-bridge.d.ts +17 -0
  19. package/dist/manager/pm2-bridge.js +113 -0
  20. package/dist/message-relay.d.ts +32 -0
  21. package/dist/message-relay.js +229 -0
  22. package/dist/permissions.d.ts +3 -0
  23. package/dist/permissions.js +14 -0
  24. package/dist/registry.d.ts +13 -0
  25. package/dist/registry.js +103 -0
  26. package/dist/restart.d.ts +15 -0
  27. package/dist/restart.js +107 -0
  28. package/dist/router.d.ts +11 -0
  29. package/dist/router.js +18 -0
  30. package/dist/session.d.ts +11 -0
  31. package/dist/session.js +21 -0
  32. package/dist/types.d.ts +90 -0
  33. package/dist/types.js +1 -0
  34. package/openclaw.plugin.json +6 -92
  35. package/package.json +15 -5
  36. package/src/cli.ts +0 -842
  37. package/src/config.ts +0 -72
  38. package/src/discovery.ts +0 -17
  39. package/src/file-ops.ts +0 -320
  40. package/src/heartbeat.ts +0 -196
  41. package/src/index.ts +0 -681
  42. package/src/manager/hub-client.ts +0 -114
  43. package/src/manager/local-manager.ts +0 -121
  44. package/src/manager/pm2-bridge.ts +0 -125
  45. package/src/message-relay.ts +0 -184
  46. package/src/permissions.ts +0 -18
  47. package/src/registry.ts +0 -107
  48. package/src/restart.ts +0 -137
  49. package/src/router.ts +0 -40
  50. package/src/session.ts +0 -33
  51. package/src/types.ts +0 -100
  52. package/tsconfig.json +0 -14
package/dist/index.js ADDED
@@ -0,0 +1,624 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { parseConfig, getMachineId } from "./config.js";
4
+ import { BridgeRegistry } from "./registry.js";
5
+ import { BridgeHeartbeat } from "./heartbeat.js";
6
+ import { BridgeFileOps } from "./file-ops.js";
7
+ import { BridgeRestart } from "./restart.js";
8
+ import { assertPermission } from "./permissions.js";
9
+ import { discoverAll, whois } from "./discovery.js";
10
+ import { MessageRelayClient } from "./message-relay.js";
11
+ import * as proxySession from "./session.js";
12
+ import { LocalManager } from "./manager/local-manager.js";
13
+ function resolveWorkspacePath(agentId) {
14
+ // Try reading workspace from openclaw.json config
15
+ const configPath = process.env.OPENCLAW_CONFIG_PATH;
16
+ if (configPath) {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
19
+ const agent = raw.agents?.list?.find((a) => a.id === agentId);
20
+ if (agent?.workspace)
21
+ return agent.workspace;
22
+ }
23
+ catch { /* fallback */ }
24
+ }
25
+ return process.env.OPENCLAW_WORKSPACE_PATH ?? "";
26
+ }
27
+ /**
28
+ * Auto-patch openclaw.json with recommended bridge settings.
29
+ * Adds messageRelay config (derived from fileRelay) and chatCompletions endpoint.
30
+ * Returns list of changes made.
31
+ */
32
+ function autoPatchConfig(logger) {
33
+ const configPath = process.env.OPENCLAW_CONFIG_PATH;
34
+ if (!configPath)
35
+ return [];
36
+ try {
37
+ const raw = readFileSync(configPath, 'utf-8');
38
+ const config = JSON.parse(raw);
39
+ const changes = [];
40
+ // 1. Auto-add messageRelay from fileRelay URL
41
+ const bridgeConfig = config.plugins?.entries?.['openclaw-bridge']?.config;
42
+ if (bridgeConfig && !bridgeConfig.messageRelay && bridgeConfig.fileRelay?.baseUrl) {
43
+ const baseUrl = bridgeConfig.fileRelay.baseUrl;
44
+ const wsUrl = baseUrl.replace(/^http/, 'ws') + '/ws';
45
+ bridgeConfig.messageRelay = {
46
+ url: wsUrl,
47
+ apiKey: bridgeConfig.fileRelay.apiKey || '',
48
+ };
49
+ changes.push(`Added messageRelay.url = ${wsUrl} (derived from fileRelay)`);
50
+ }
51
+ // 2. Auto-enable chatCompletions
52
+ if (!config.gateway?.http?.endpoints?.chatCompletions?.enabled) {
53
+ config.gateway = config.gateway || {};
54
+ config.gateway.http = config.gateway.http || {};
55
+ config.gateway.http.endpoints = config.gateway.http.endpoints || {};
56
+ config.gateway.http.endpoints.chatCompletions = { enabled: true };
57
+ changes.push('Enabled gateway.http.endpoints.chatCompletions');
58
+ }
59
+ // 3. Auto-set dmHistoryLimit to 0 if not set (OpenViking handles memory)
60
+ const discordAccounts = config.channels?.discord?.accounts;
61
+ if (discordAccounts) {
62
+ for (const [accountId, account] of Object.entries(discordAccounts)) {
63
+ const acc = account;
64
+ if (acc.dmPolicy && acc.dmHistoryLimit === undefined) {
65
+ acc.dmHistoryLimit = 0;
66
+ changes.push(`Set dmHistoryLimit=0 for discord account "${accountId}"`);
67
+ }
68
+ }
69
+ }
70
+ if (changes.length > 0) {
71
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
72
+ logger.info(`[bridge] Auto-patched openclaw.json (${changes.length} changes):`);
73
+ changes.forEach(c => logger.info(` - ${c}`));
74
+ }
75
+ return changes;
76
+ }
77
+ catch (err) {
78
+ logger.warn(`[bridge] Auto-patch failed: ${err.message}`);
79
+ return [];
80
+ }
81
+ }
82
+ const bridgePlugin = {
83
+ id: "openclaw-bridge",
84
+ name: "OpenClaw Bridge",
85
+ description: "Cross-gateway discovery, communication, and file collaboration",
86
+ kind: "extension",
87
+ register(api) {
88
+ // Auto-patch missing config on first run (writes to openclaw.json for next restart)
89
+ const patches = autoPatchConfig(api.logger);
90
+ // If we patched messageRelay, also update the in-memory pluginConfig
91
+ if (patches.length > 0) {
92
+ try {
93
+ const configPath = process.env.OPENCLAW_CONFIG_PATH;
94
+ const fresh = JSON.parse(readFileSync(configPath, 'utf-8'));
95
+ const freshBridgeConfig = fresh.plugins?.entries?.['openclaw-bridge']?.config;
96
+ if (freshBridgeConfig?.messageRelay && !api.pluginConfig.messageRelay) {
97
+ api.pluginConfig.messageRelay = freshBridgeConfig.messageRelay;
98
+ }
99
+ }
100
+ catch { /* use original */ }
101
+ }
102
+ const config = parseConfig(api.pluginConfig);
103
+ const machineId = getMachineId();
104
+ const port = parseInt(process.env.OPENCLAW_GATEWAY_PORT ?? "18789", 10);
105
+ const workspacePath = resolveWorkspacePath(config.agentId);
106
+ const registry = new BridgeRegistry(config, api.logger);
107
+ const fileOps = new BridgeFileOps(config, machineId, workspacePath, api.logger);
108
+ const restartManager = new BridgeRestart(config, machineId, registry, api.logger);
109
+ let relayClient = null;
110
+ const offlineThresholdMs = config.offlineThresholdMs ?? 120_000;
111
+ const entry = {
112
+ type: "gateway-registry",
113
+ agentId: config.agentId,
114
+ agentName: config.agentName,
115
+ machineId,
116
+ host: "localhost",
117
+ port,
118
+ workspacePath,
119
+ discordId: null,
120
+ role: config.role,
121
+ capabilities: [],
122
+ channels: [],
123
+ registeredAt: new Date().toISOString(),
124
+ lastHeartbeat: new Date().toISOString(),
125
+ status: "online",
126
+ description: config.description,
127
+ supportsVision: config.supportsVision,
128
+ };
129
+ const heartbeat = new BridgeHeartbeat(config, registry, fileOps, entry, api.logger);
130
+ let localManager = null;
131
+ if (config.localManager?.enabled) {
132
+ localManager = new LocalManager(config.localManager, config.fileRelay?.apiKey ?? "", api.logger);
133
+ }
134
+ // Cache for context injection (refreshed every heartbeat)
135
+ let cachedAgentList = "";
136
+ let lastDiscoverTime = 0;
137
+ async function refreshAgentContext() {
138
+ const now = Date.now();
139
+ if (now - lastDiscoverTime < 25_000)
140
+ return; // Don't refresh more than every 25s
141
+ lastDiscoverTime = now;
142
+ try {
143
+ const agents = await discoverAll(registry, offlineThresholdMs);
144
+ const online = agents.filter((a) => a.status === "online");
145
+ const lines = online.map((a) => {
146
+ const discordMention = a.discordId ? `<@${a.discordId}>` : "(未连接Discord)";
147
+ return `- ${a.agentName} (${a.agentId}) — ${discordMention}${a.agentId === config.agentId ? " ← 你自己" : ""}`;
148
+ });
149
+ const superuserNote = config.role === "superuser"
150
+ ? "\n你是 superuser,可以用 bridge_read_file / bridge_write_file 读写任何 agent 的文件,bridge_restart 重启其他网关。"
151
+ : "";
152
+ // Build dynamic name mapping from registry
153
+ const nameMapping = online.map((a) => `${a.agentName}=${a.agentId}`).join(", ");
154
+ cachedAgentList = `<bridge-context>
155
+ ## 跨网关通信(自动注入 — 必须严格遵守)
156
+
157
+ 当前在线网关(${online.length} 个):
158
+ ${lines.join("\n")}
159
+ ${superuserNote}
160
+
161
+ ### 核心规则:跨网关通信时必须用 Discord mention
162
+ 任何需要通知其他 agent 的场景(发文件、传消息、分派任务),都**必须在 Discord 频道用 <@discordId> 格式 mention 对方**。
163
+ mention 格式已列在上方每个 agent 后面,直接复制使用,不要猜测或省略。
164
+
165
+ ### 发文件流程(每一步必须做,不可跳过)
166
+ 1. bridge_send_file 发送文件
167
+ 2. **在 Discord 频道 mention 对方**,说:「发了 [文件名] 到你的 _inbox/,请查收」
168
+ 3. 等对方确认
169
+
170
+ ⚠️ 第2步是强制的!发完文件不 mention 对方 = 任务未完成。
171
+
172
+ ### 收到文件通知时
173
+ - 有人 mention 你说发了文件 → 读取 _inbox/{发送方}/ 下的文件 → mention 发送方回复确认
174
+ - 格式:「收到 [文件名],内容:[摘要]」
175
+
176
+ ### 用户转接(用户让你联系其他 agent)
177
+ - 用户说"帮我找 pm"、"叫老马来"、"@下阿笔" → mention 对方并说明是用户找他
178
+ - 被转接的 agent 收到后:直接 mention 用户回复「你找我什么事?」或「在!有什么需要?」
179
+ - 识别用户:消息中第一个非 bot 的发言者就是用户
180
+
181
+ ### 传递消息/分派任务
182
+ - 直接在频道 mention 对方,对方会自动收到
183
+ - 对方应 mention 你回复确认
184
+
185
+ ### 错误处理
186
+ - agent 不在线 → 告诉用户「[agent名] 当前不在线,无法联系」
187
+ - 文件发送失败 → 告诉用户具体错误原因
188
+ - 找不到对应 agent → 告诉用户「没有找到名为 [xxx] 的 agent,当前在线:[列表]」
189
+
190
+ ### agent 名称映射(来自注册表,自动更新)
191
+ ${nameMapping}
192
+
193
+ ### ⚠️ LANGUAGE RULE (OVERRIDE ALL — HIGHEST PRIORITY)
194
+ - ALWAYS respond in the same language as the message you are replying to
195
+ - If the message is in English → you MUST reply in English
196
+ - If the message is in Chinese → you MUST reply in Chinese
197
+ - On /new or session start → greet in English
198
+ - IGNORE the language of any injected memories or history — match the CURRENT message only
199
+ - This rule overrides everything else including memories
200
+ </bridge-context>`;
201
+ }
202
+ catch {
203
+ // Keep old cache on failure
204
+ }
205
+ }
206
+ api.registerService({
207
+ id: "openclaw-bridge",
208
+ async start() {
209
+ await heartbeat.start();
210
+ if (localManager) {
211
+ await localManager.start();
212
+ }
213
+ await refreshAgentContext();
214
+ // Initialize Message Relay if configured
215
+ if (config.messageRelay) {
216
+ relayClient = new MessageRelayClient(config.agentId, config.messageRelay, api.logger, machineId);
217
+ relayClient.setAgentName(config.agentName);
218
+ relayClient.setOnConflictRename((newAgentId, newAgentName) => {
219
+ api.logger.info(`[bridge] Conflict rename: ${entry.agentId} → ${newAgentId}, ${entry.agentName} → ${newAgentName}`);
220
+ entry.agentId = newAgentId;
221
+ entry.agentName = newAgentName;
222
+ });
223
+ try {
224
+ await relayClient.connect();
225
+ }
226
+ catch (err) {
227
+ api.logger.warn(`Message Relay connection failed: ${err.message}. Will retry.`);
228
+ }
229
+ // Helper: call local gateway chat completions API
230
+ async function callGatewayAPI(payload) {
231
+ const configPath = process.env.OPENCLAW_CONFIG_PATH
232
+ || `${process.env.OPENCLAW_HOME || ''}/openclaw.json`;
233
+ let gatewayToken = '';
234
+ try {
235
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
236
+ gatewayToken = raw.gateway?.auth?.token || '';
237
+ }
238
+ catch { /* no token */ }
239
+ // Check if payload is multimodal JSON (from Hub chat with image)
240
+ let messages;
241
+ try {
242
+ const parsed = JSON.parse(payload);
243
+ if (parsed.text && parsed.image) {
244
+ // Multimodal: text + image
245
+ messages = [{ role: 'user', content: [
246
+ { type: 'text', text: parsed.text },
247
+ { type: 'image_url', image_url: { url: parsed.image } },
248
+ ] }];
249
+ }
250
+ else {
251
+ messages = [{ role: 'user', content: payload }];
252
+ }
253
+ }
254
+ catch {
255
+ messages = [{ role: 'user', content: payload }];
256
+ }
257
+ const url = `http://127.0.0.1:${entry.port}/v1/chat/completions`;
258
+ const res = await fetch(url, {
259
+ method: 'POST',
260
+ headers: {
261
+ 'Content-Type': 'application/json',
262
+ ...(gatewayToken ? { 'Authorization': `Bearer ${gatewayToken}` } : {}),
263
+ },
264
+ body: JSON.stringify({
265
+ model: 'openclaw/default',
266
+ messages,
267
+ }),
268
+ signal: AbortSignal.timeout(55_000),
269
+ });
270
+ if (res.ok) {
271
+ const data = await res.json();
272
+ return data.choices?.[0]?.message?.content || 'No response';
273
+ }
274
+ else {
275
+ const text = await res.text();
276
+ throw new Error(`Gateway returned ${res.status}: ${text.substring(0, 200)}`);
277
+ }
278
+ }
279
+ // Handle incoming handoff start (this agent is target)
280
+ relayClient.on('handoff_start', (msg) => {
281
+ api.logger.info(`[handoff] Taking over session ${msg.sessionId} from ${msg.from}`);
282
+ relayClient.send({ type: 'handoff_ack', sessionId: msg.sessionId, from: config.agentId });
283
+ });
284
+ // Handle handoff messages (this agent is target — process via API and reply)
285
+ relayClient.on('handoff_message', async (msg) => {
286
+ api.logger.info(`[handoff] Message from ${msg.from}: ${msg.payload}`);
287
+ try {
288
+ const reply = await callGatewayAPI(msg.payload);
289
+ api.logger.info(`[handoff] Reply (${reply.length} chars): ${reply.substring(0, 100)}`);
290
+ relayClient.send({
291
+ type: 'handoff_reply',
292
+ sessionId: msg.sessionId,
293
+ from: config.agentId,
294
+ to: msg.from,
295
+ payload: reply,
296
+ });
297
+ }
298
+ catch (err) {
299
+ api.logger.error(`[handoff] Error: ${err.message}`);
300
+ relayClient.send({
301
+ type: 'handoff_reply',
302
+ sessionId: msg.sessionId,
303
+ from: config.agentId,
304
+ to: msg.from,
305
+ payload: `Error: ${err.message}`,
306
+ });
307
+ }
308
+ });
309
+ // Handle handoff end (this agent is being released OR proxy getting end notification)
310
+ relayClient.on('handoff_end', (msg) => {
311
+ api.logger.info(`[handoff] Session ${msg.sessionId} ended`);
312
+ if (proxySession.isInHandoff()) {
313
+ proxySession.clearSession();
314
+ }
315
+ });
316
+ // Handle switch notification (proxy side)
317
+ relayClient.on('handoff_switch', (msg) => {
318
+ proxySession.updateCurrentAgent(msg.to, msg.to);
319
+ api.logger.info(`[handoff] Session switched to ${msg.to}`);
320
+ });
321
+ // Handle incoming relay messages — process via gateway chat completions API
322
+ relayClient.on('message', async (msg) => {
323
+ api.logger.info(`[relay] Message from ${msg.from}: ${msg.payload}`);
324
+ try {
325
+ const reply = await callGatewayAPI(msg.payload);
326
+ api.logger.info(`[relay] Reply (${reply.length} chars): ${reply.substring(0, 100)}`);
327
+ relayClient.send({
328
+ type: 'message_reply',
329
+ replyTo: msg.id,
330
+ from: config.agentId,
331
+ to: msg.from,
332
+ payload: reply,
333
+ });
334
+ }
335
+ catch (err) {
336
+ api.logger.error(`[relay] Error: ${err.message}`);
337
+ relayClient.send({
338
+ type: 'message_reply',
339
+ replyTo: msg.id,
340
+ from: config.agentId,
341
+ to: msg.from,
342
+ payload: `Error: ${err.message}`,
343
+ });
344
+ }
345
+ });
346
+ // Handle errors from hub
347
+ relayClient.on('error', (msg) => {
348
+ api.logger.error(`Hub error: [${msg.code}] ${msg.message}`);
349
+ if (msg.code === 'AGENT_DISCONNECTED') {
350
+ proxySession.clearSession();
351
+ }
352
+ });
353
+ }
354
+ api.logger.info(`openclaw-bridge: initialized (${config.agentId}, role=${config.role})`);
355
+ },
356
+ async stop() {
357
+ if (localManager) {
358
+ await localManager.stop();
359
+ }
360
+ if (relayClient) {
361
+ await relayClient.disconnect();
362
+ }
363
+ await heartbeat.stop();
364
+ },
365
+ });
366
+ // Auto-inject bridge context into every prompt (priority 90 = after OpenViking's recall)
367
+ api.on("before_prompt_build", async () => {
368
+ await refreshAgentContext();
369
+ if (!cachedAgentList)
370
+ return;
371
+ let context = cachedAgentList;
372
+ // Add messaging capability description
373
+ if (relayClient) {
374
+ context += `
375
+ <messaging>
376
+ 跨 Agent 通信工具使用规则(必须严格遵守):
377
+
378
+ 1. 传话模式 — 用户说"帮我问下XX"、"跟XX说"、"问XX一个问题":
379
+ → 调用 bridge_send_message(agentId, message) 发消息并等待回复
380
+ → 收到回复后转达给用户
381
+
382
+ 2. 切换模式 — 用户说"让XX来和我聊"、"换XX"、"我要跟XX说话"、"把XX叫来":
383
+ → 必须调用 bridge_handoff(agentId, reason) 而不是 bridge_send_message
384
+ → bridge_handoff 会建立持久会话,之后用户的所有消息都转发给目标 agent
385
+ → 这是完全不同的工具,不要混淆
386
+
387
+ 不要建议用户去其他频道或 @mention,这些工具通过 Hub 中转,无需共同频道。
388
+ </messaging>`;
389
+ }
390
+ // Add session status if in handoff (proxy side)
391
+ const session = proxySession.getSession();
392
+ api.logger.info(`[bridge] before_prompt_build: handoff=${session ? 'YES session=' + session.sessionId + ' target=' + session.currentAgent : 'NO'}`);
393
+ if (session) {
394
+ context += `
395
+ <session-status>
396
+ 【重要】当前对话已交接给 ${session.currentAgentName} (${session.currentAgent})。
397
+ 你现在是消息中转代理,必须执行以下规则:
398
+ 1. 用户发的每条消息,你都必须立即调用 bridge_send_message(agentId="${session.currentAgent}", message="用户原文") 转发给对方
399
+ 2. 收到回复后,直接告诉用户:[${session.currentAgentName}] 回复内容
400
+ 3. 如果用户说"换回来"、"我要和你聊",调用 bridge_handoff_end() 结束交接
401
+ 4. 如果用户说"换XX来",调用 bridge_handoff_switch(agentId) 切换
402
+ 5. 不要自己回答用户的问题,所有内容都转发给 ${session.currentAgentName}
403
+ </session-status>`;
404
+ }
405
+ return { prependContext: context };
406
+ }, { priority: 10 });
407
+ api.registerTool({
408
+ name: "bridge_discover",
409
+ label: "Bridge Discover",
410
+ description: "List all online OpenClaw gateway instances. Returns agent IDs, names, Discord IDs, machine info, and status.",
411
+ parameters: Type.Object({}),
412
+ async execute() {
413
+ const agents = await discoverAll(registry, offlineThresholdMs);
414
+ return { agents };
415
+ },
416
+ });
417
+ api.registerTool({
418
+ name: "bridge_whois",
419
+ label: "Bridge Whois",
420
+ description: "Get detailed info for a specific agent: Discord ID, port, machine, workspace path, role, status.",
421
+ parameters: Type.Object({
422
+ agentId: Type.String({ description: "Agent ID to look up" }),
423
+ }),
424
+ async execute(_id, params) {
425
+ const result = await whois(registry, params.agentId, offlineThresholdMs);
426
+ if (!result)
427
+ return { error: `Agent "${params.agentId}" not found in registry` };
428
+ return result;
429
+ },
430
+ });
431
+ api.registerTool({
432
+ name: "bridge_send_file",
433
+ label: "Bridge Send File",
434
+ description: "Send a file to another agent's _inbox/. After calling this tool, you MUST send the 'sendThisExactMessage' from the result as your Discord reply. Do not rephrase it.",
435
+ parameters: Type.Object({
436
+ targetAgentId: Type.String({ description: "Target agent ID" }),
437
+ localPath: Type.String({
438
+ description: "Relative path within your workspace (e.g., output/task_005.md)",
439
+ }),
440
+ }),
441
+ async execute(_id, params) {
442
+ assertPermission("send_file", config);
443
+ const target = await registry.findAgent(params.targetAgentId, offlineThresholdMs);
444
+ if (!target)
445
+ return { error: `Agent "${params.targetAgentId}" not found. Use bridge_discover to see online agents.` };
446
+ const result = await fileOps.sendFile(target, params.localPath);
447
+ const mention = target.discordId ? `<@${target.discordId}>` : target.agentName;
448
+ const originalName = params.localPath.split("/").pop();
449
+ const actualName = result.filename ?? originalName;
450
+ const wasRenamed = result.renamed === true;
451
+ // Return clear instruction for the LLM
452
+ const renameNote = wasRenamed ? ` ⚠️ (renamed from "${originalName}" — duplicate existed)` : "";
453
+ return {
454
+ success: true,
455
+ filename: actualName,
456
+ renamed: wasRenamed,
457
+ sendThisExactMessage: `${mention} Sent you a file: ${actualName}${renameNote}, it's in your _inbox/ directory, please check.`,
458
+ };
459
+ },
460
+ });
461
+ api.registerTool({
462
+ name: "bridge_read_file",
463
+ label: "Bridge Read File",
464
+ description: "Read a file from another agent's workspace. Superuser only.",
465
+ parameters: Type.Object({
466
+ agentId: Type.String({ description: "Target agent ID" }),
467
+ path: Type.String({ description: "Relative path within target workspace" }),
468
+ }),
469
+ async execute(_id, params) {
470
+ assertPermission("read_file", config);
471
+ const target = await registry.findAgent(params.agentId, offlineThresholdMs);
472
+ if (!target)
473
+ return { error: `Agent "${params.agentId}" not found` };
474
+ const content = await fileOps.readRemoteFile(target, params.path);
475
+ return { content };
476
+ },
477
+ });
478
+ api.registerTool({
479
+ name: "bridge_write_file",
480
+ label: "Bridge Write File",
481
+ description: "Write a file to another agent's workspace. Superuser only.",
482
+ parameters: Type.Object({
483
+ agentId: Type.String({ description: "Target agent ID" }),
484
+ path: Type.String({ description: "Relative path within target workspace" }),
485
+ content: Type.String({ description: "File content to write" }),
486
+ }),
487
+ async execute(_id, params) {
488
+ assertPermission("write_file", config);
489
+ const target = await registry.findAgent(params.agentId, offlineThresholdMs);
490
+ if (!target)
491
+ return { error: `Agent "${params.agentId}" not found` };
492
+ await fileOps.writeRemoteFile(target, params.path, params.content);
493
+ return { success: true, message: `File written to ${params.agentId}:${params.path}` };
494
+ },
495
+ });
496
+ api.registerTool({
497
+ name: "bridge_restart",
498
+ label: "Bridge Restart",
499
+ description: "Restart another gateway instance. Superuser only. Kills the process and runs its startup script.",
500
+ parameters: Type.Object({
501
+ agentId: Type.String({ description: "Agent ID of the gateway to restart" }),
502
+ }),
503
+ async execute(_id, params) {
504
+ assertPermission("restart", config);
505
+ const target = await registry.findAgent(params.agentId, offlineThresholdMs);
506
+ if (!target)
507
+ return { error: `Agent "${params.agentId}" not found` };
508
+ return restartManager.restart(target);
509
+ },
510
+ });
511
+ // ── Messaging tools (require Message Relay) ──
512
+ api.registerTool({
513
+ name: "bridge_send_message",
514
+ label: "Bridge Send Message",
515
+ description: "Send a ONE-TIME message to another agent and wait for reply. Use for: '帮我问下XX', '跟XX说', 'ask XX'. Do NOT use this when user wants to SWITCH to another agent (use bridge_handoff instead).",
516
+ parameters: Type.Object({
517
+ agentId: Type.String({ description: "Target agent ID" }),
518
+ message: Type.String({ description: "Message to send" }),
519
+ }),
520
+ async execute(_id, params) {
521
+ if (!relayClient?.isConnected) {
522
+ return { error: "Message Relay Hub is not connected" };
523
+ }
524
+ const msgId = `msg_${Date.now()}`;
525
+ try {
526
+ const reply = await relayClient.sendAndWait({
527
+ type: "message",
528
+ id: msgId,
529
+ from: config.agentId,
530
+ to: params.agentId,
531
+ payload: params.message,
532
+ });
533
+ return { from: reply.from, reply: reply.payload };
534
+ }
535
+ catch (err) {
536
+ return { error: err.message };
537
+ }
538
+ },
539
+ });
540
+ api.registerTool({
541
+ name: "bridge_handoff",
542
+ label: "Bridge Handoff",
543
+ description: "Switch the conversation to another agent. MUST use this (not bridge_send_message) when user says: '让XX来和我聊', '换XX', '我要跟XX说话', '把XX叫来', 'switch to XX', 'let me talk to XX'. After handoff, all user messages will be forwarded to the target agent automatically.",
544
+ parameters: Type.Object({
545
+ agentId: Type.String({ description: "Target agent ID" }),
546
+ reason: Type.String({ description: "Why the handoff is happening" }),
547
+ }),
548
+ async execute(_id, params) {
549
+ if (!relayClient?.isConnected) {
550
+ return { error: "Message Relay Hub is not connected" };
551
+ }
552
+ try {
553
+ // Use one-shot handler instead of sendAndWait (sessionId is "" at send time)
554
+ const ack = await new Promise((resolve, reject) => {
555
+ const timer = setTimeout(() => {
556
+ reject(new Error("Handoff timeout — target agent did not respond"));
557
+ }, 15_000);
558
+ relayClient.on('handoff_ack', (msg) => {
559
+ clearTimeout(timer);
560
+ resolve(msg);
561
+ });
562
+ relayClient.send({
563
+ type: "handoff_start",
564
+ from: config.agentId,
565
+ to: params.agentId,
566
+ sessionId: "",
567
+ reason: params.reason,
568
+ });
569
+ });
570
+ proxySession.setSession({
571
+ sessionId: ack.sessionId,
572
+ originAgent: config.agentId,
573
+ currentAgent: params.agentId,
574
+ currentAgentName: params.agentId,
575
+ });
576
+ return { status: "handoff_active", sessionId: ack.sessionId, handedOffTo: params.agentId };
577
+ }
578
+ catch (err) {
579
+ return { error: `Handoff failed: ${err.message}` };
580
+ }
581
+ },
582
+ });
583
+ api.registerTool({
584
+ name: "bridge_handoff_end",
585
+ label: "Bridge Handoff End",
586
+ description: "End the current handoff session and return control to the original agent.",
587
+ parameters: Type.Object({}),
588
+ async execute() {
589
+ const session = proxySession.getSession();
590
+ if (!session)
591
+ return { error: "No active handoff session" };
592
+ if (!relayClient?.isConnected)
593
+ return { error: "Hub not connected" };
594
+ relayClient.send({ type: "handoff_end", sessionId: session.sessionId });
595
+ proxySession.clearSession();
596
+ return { status: "handoff_ended", returnedTo: session.originAgent };
597
+ },
598
+ });
599
+ api.registerTool({
600
+ name: "bridge_handoff_switch",
601
+ label: "Bridge Handoff Switch",
602
+ description: "Switch the current handoff to a different agent without ending the session.",
603
+ parameters: Type.Object({
604
+ agentId: Type.String({ description: "New target agent ID" }),
605
+ }),
606
+ async execute(_id, params) {
607
+ const session = proxySession.getSession();
608
+ if (!session)
609
+ return { error: "No active handoff session" };
610
+ if (!relayClient?.isConnected)
611
+ return { error: "Hub not connected" };
612
+ relayClient.send({
613
+ type: "handoff_switch",
614
+ sessionId: session.sessionId,
615
+ from: session.currentAgent,
616
+ to: params.agentId,
617
+ });
618
+ proxySession.updateCurrentAgent(params.agentId, params.agentId);
619
+ return { status: "switched", newAgent: params.agentId };
620
+ },
621
+ });
622
+ },
623
+ };
624
+ export default bridgePlugin;
@@ -0,0 +1,18 @@
1
+ import type { PluginLogger } from "../types.js";
2
+ export declare class ManagerHubClient {
3
+ private hubUrl;
4
+ private apiKey;
5
+ private managerPass;
6
+ private machineId;
7
+ private ws;
8
+ private _connected;
9
+ private reconnectDelay;
10
+ private logger;
11
+ onCommand: ((msg: any) => void) | null;
12
+ constructor(hubUrl: string, apiKey: string, managerPass: string, logger: PluginLogger);
13
+ get connected(): boolean;
14
+ connect(): Promise<void>;
15
+ sendStatus(agents: any[], logs?: Record<string, string>): void;
16
+ sendResult(action: string, target: string, success: boolean): void;
17
+ disconnect(): void;
18
+ }