multiclaws 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/README.zh-CN.md +36 -0
- package/dist/gateway/handlers.d.ts +3 -0
- package/dist/gateway/handlers.js +172 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +446 -0
- package/dist/infra/gateway-client.d.ts +27 -0
- package/dist/infra/gateway-client.js +136 -0
- package/dist/infra/json-store.d.ts +4 -0
- package/dist/infra/json-store.js +57 -0
- package/dist/infra/logger.d.ts +13 -0
- package/dist/infra/logger.js +18 -0
- package/dist/infra/rate-limiter.d.ts +19 -0
- package/dist/infra/rate-limiter.js +69 -0
- package/dist/infra/tailscale.d.ts +19 -0
- package/dist/infra/tailscale.js +120 -0
- package/dist/infra/telemetry.d.ts +3 -0
- package/dist/infra/telemetry.js +17 -0
- package/dist/service/a2a-adapter.d.ts +44 -0
- package/dist/service/a2a-adapter.js +208 -0
- package/dist/service/agent-profile.d.ts +13 -0
- package/dist/service/agent-profile.js +38 -0
- package/dist/service/agent-registry.d.ts +26 -0
- package/dist/service/agent-registry.js +100 -0
- package/dist/service/multiclaws-service.d.ts +88 -0
- package/dist/service/multiclaws-service.js +708 -0
- package/dist/task/tracker.d.ts +43 -0
- package/dist/task/tracker.js +186 -0
- package/dist/team/team-store.d.ts +39 -0
- package/dist/team/team-store.js +146 -0
- package/dist/types/openclaw.d.ts +86 -0
- package/dist/types/openclaw.js +2 -0
- package/openclaw.plugin.json +34 -0
- package/package.json +56 -0
- package/skills/multiclaws/SKILL.md +164 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MulticlawsService = void 0;
|
|
7
|
+
const node_events_1 = require("node:events");
|
|
8
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
9
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
12
|
+
const tailscale_1 = require("../infra/tailscale");
|
|
13
|
+
const json_store_1 = require("../infra/json-store");
|
|
14
|
+
const express_1 = __importDefault(require("express"));
|
|
15
|
+
const server_1 = require("@a2a-js/sdk/server");
|
|
16
|
+
const express_2 = require("@a2a-js/sdk/server/express");
|
|
17
|
+
const client_1 = require("@a2a-js/sdk/client");
|
|
18
|
+
const a2a_adapter_1 = require("./a2a-adapter");
|
|
19
|
+
const agent_registry_1 = require("./agent-registry");
|
|
20
|
+
const agent_profile_1 = require("./agent-profile");
|
|
21
|
+
const team_store_1 = require("../team/team-store");
|
|
22
|
+
const tracker_1 = require("../task/tracker");
|
|
23
|
+
const zod_1 = require("zod");
|
|
24
|
+
const gateway_client_1 = require("../infra/gateway-client");
|
|
25
|
+
const rate_limiter_1 = require("../infra/rate-limiter");
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Service */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
class MulticlawsService extends node_events_1.EventEmitter {
|
|
30
|
+
options;
|
|
31
|
+
started = false;
|
|
32
|
+
httpServer = null;
|
|
33
|
+
agentRegistry;
|
|
34
|
+
teamStore;
|
|
35
|
+
profileStore;
|
|
36
|
+
taskTracker;
|
|
37
|
+
agentExecutor = null;
|
|
38
|
+
a2aRequestHandler = null;
|
|
39
|
+
agentCard = null;
|
|
40
|
+
clientFactory = new client_1.ClientFactory();
|
|
41
|
+
httpRateLimiter = new rate_limiter_1.RateLimiter({ windowMs: 60_000, maxRequests: 60 });
|
|
42
|
+
selfUrl;
|
|
43
|
+
profileDescription = "OpenClaw agent";
|
|
44
|
+
constructor(options) {
|
|
45
|
+
super();
|
|
46
|
+
this.options = options;
|
|
47
|
+
const multiclawsStateDir = node_path_1.default.join(options.stateDir, "multiclaws");
|
|
48
|
+
this.agentRegistry = new agent_registry_1.AgentRegistry(node_path_1.default.join(multiclawsStateDir, "agents.json"));
|
|
49
|
+
this.teamStore = new team_store_1.TeamStore(node_path_1.default.join(multiclawsStateDir, "teams.json"));
|
|
50
|
+
this.profileStore = new agent_profile_1.ProfileStore(node_path_1.default.join(multiclawsStateDir, "profile.json"));
|
|
51
|
+
this.taskTracker = new tracker_1.TaskTracker({
|
|
52
|
+
filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
|
|
53
|
+
});
|
|
54
|
+
const port = options.port ?? 3100;
|
|
55
|
+
// selfUrl resolved later in start() after Tailscale detection; use placeholder for now
|
|
56
|
+
this.selfUrl = options.selfUrl ?? `http://${getLocalIp()}:${port}`;
|
|
57
|
+
}
|
|
58
|
+
async start() {
|
|
59
|
+
if (this.started)
|
|
60
|
+
return;
|
|
61
|
+
// Auto-detect Tailscale if selfUrl not explicitly configured
|
|
62
|
+
if (!this.options.selfUrl) {
|
|
63
|
+
const port = this.options.port ?? 3100;
|
|
64
|
+
// Fast path: Tailscale already active — just read from network interfaces, no subprocess
|
|
65
|
+
const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
|
|
66
|
+
if (tsIp) {
|
|
67
|
+
this.selfUrl = `http://${tsIp}:${port}`;
|
|
68
|
+
this.log("info", `Tailscale IP detected: ${tsIp}`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Slow path: Tailscale not active — run full detection and notify user
|
|
72
|
+
const tailscale = await (0, tailscale_1.detectTailscale)();
|
|
73
|
+
if (tailscale.status === "ready") {
|
|
74
|
+
this.selfUrl = `http://${tailscale.ip}:${port}`;
|
|
75
|
+
this.log("info", `Tailscale IP detected: ${tailscale.ip}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
void this.notifyTailscaleSetup(tailscale);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Load profile for AgentCard description
|
|
83
|
+
let profile = await this.profileStore.load();
|
|
84
|
+
if (!profile.ownerName?.trim()) {
|
|
85
|
+
profile.ownerName = this.options.displayName ?? node_os_1.default.hostname();
|
|
86
|
+
await this.profileStore.save(profile);
|
|
87
|
+
await this.setPendingProfileReview();
|
|
88
|
+
}
|
|
89
|
+
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
90
|
+
const logger = this.options.logger ?? { info: () => { }, warn: () => { }, error: () => { } };
|
|
91
|
+
this.agentExecutor = new a2a_adapter_1.OpenClawAgentExecutor({
|
|
92
|
+
gatewayConfig: this.options.gatewayConfig ?? null,
|
|
93
|
+
taskTracker: this.taskTracker,
|
|
94
|
+
logger,
|
|
95
|
+
});
|
|
96
|
+
this.agentCard = {
|
|
97
|
+
name: this.options.displayName ?? (profile.ownerName || "OpenClaw Agent"),
|
|
98
|
+
description: this.profileDescription,
|
|
99
|
+
url: this.selfUrl,
|
|
100
|
+
version: "0.3.0",
|
|
101
|
+
protocolVersion: "0.2.2",
|
|
102
|
+
defaultInputModes: ["text/plain"],
|
|
103
|
+
defaultOutputModes: ["text/plain"],
|
|
104
|
+
capabilities: { streaming: false, pushNotifications: false },
|
|
105
|
+
skills: [
|
|
106
|
+
{
|
|
107
|
+
id: "general",
|
|
108
|
+
name: "General Task",
|
|
109
|
+
description: "Execute any delegated task via OpenClaw",
|
|
110
|
+
tags: ["task", "delegation", "general"],
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
const taskStore = new server_1.InMemoryTaskStore();
|
|
115
|
+
this.a2aRequestHandler = new server_1.DefaultRequestHandler(this.agentCard, taskStore, this.agentExecutor);
|
|
116
|
+
const app = (0, express_1.default)();
|
|
117
|
+
app.use(express_1.default.json({ limit: "1mb" }));
|
|
118
|
+
// Rate limiting
|
|
119
|
+
app.use((req, res, next) => {
|
|
120
|
+
const clientIp = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
121
|
+
if (!this.httpRateLimiter.allow(clientIp)) {
|
|
122
|
+
res.status(429).json({ error: "rate limited" });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
next();
|
|
126
|
+
});
|
|
127
|
+
// Team + profile REST endpoints
|
|
128
|
+
this.mountTeamRoutes(app);
|
|
129
|
+
// A2A endpoints
|
|
130
|
+
app.use("/.well-known/agent-card.json", (0, express_2.agentCardHandler)({
|
|
131
|
+
agentCardProvider: this.a2aRequestHandler,
|
|
132
|
+
}));
|
|
133
|
+
app.use("/", (0, express_2.jsonRpcHandler)({
|
|
134
|
+
requestHandler: this.a2aRequestHandler,
|
|
135
|
+
userBuilder: express_2.UserBuilder.noAuthentication,
|
|
136
|
+
}));
|
|
137
|
+
const listenPort = this.options.port ?? 3100;
|
|
138
|
+
this.httpServer = node_http_1.default.createServer(app);
|
|
139
|
+
await new Promise((resolve) => this.httpServer.listen(listenPort, "0.0.0.0", resolve));
|
|
140
|
+
this.started = true;
|
|
141
|
+
this.log("info", `multiclaws A2A service listening on :${listenPort}`);
|
|
142
|
+
}
|
|
143
|
+
async stop() {
|
|
144
|
+
if (!this.started)
|
|
145
|
+
return;
|
|
146
|
+
this.started = false;
|
|
147
|
+
this.taskTracker.destroy();
|
|
148
|
+
this.httpRateLimiter.destroy();
|
|
149
|
+
await new Promise((resolve) => {
|
|
150
|
+
if (!this.httpServer) {
|
|
151
|
+
resolve();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.httpServer.close(() => resolve());
|
|
155
|
+
});
|
|
156
|
+
this.httpServer = null;
|
|
157
|
+
}
|
|
158
|
+
updateGatewayConfig(config) {
|
|
159
|
+
this.agentExecutor?.updateGatewayConfig(config);
|
|
160
|
+
}
|
|
161
|
+
/* ---------------------------------------------------------------- */
|
|
162
|
+
/* Agent management */
|
|
163
|
+
/* ---------------------------------------------------------------- */
|
|
164
|
+
async listAgents() {
|
|
165
|
+
return await this.agentRegistry.list();
|
|
166
|
+
}
|
|
167
|
+
async addAgent(params) {
|
|
168
|
+
const normalizedUrl = params.url.replace(/\/+$/, "");
|
|
169
|
+
try {
|
|
170
|
+
const client = await this.clientFactory.createFromUrl(normalizedUrl);
|
|
171
|
+
const card = await client.getAgentCard();
|
|
172
|
+
return await this.agentRegistry.add({
|
|
173
|
+
url: normalizedUrl,
|
|
174
|
+
name: card.name ?? normalizedUrl,
|
|
175
|
+
description: card.description ?? "",
|
|
176
|
+
skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
|
|
177
|
+
apiKey: params.apiKey,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return await this.agentRegistry.add({
|
|
182
|
+
url: normalizedUrl,
|
|
183
|
+
name: normalizedUrl,
|
|
184
|
+
apiKey: params.apiKey,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async removeAgent(url) {
|
|
189
|
+
return await this.agentRegistry.remove(url);
|
|
190
|
+
}
|
|
191
|
+
/* ---------------------------------------------------------------- */
|
|
192
|
+
/* Task delegation */
|
|
193
|
+
/* ---------------------------------------------------------------- */
|
|
194
|
+
async delegateTask(params) {
|
|
195
|
+
const agentRecord = await this.agentRegistry.get(params.agentUrl);
|
|
196
|
+
if (!agentRecord) {
|
|
197
|
+
return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
|
|
198
|
+
}
|
|
199
|
+
const track = this.taskTracker.create({
|
|
200
|
+
fromPeerId: "local",
|
|
201
|
+
toPeerId: params.agentUrl,
|
|
202
|
+
task: params.task,
|
|
203
|
+
});
|
|
204
|
+
this.taskTracker.update(track.taskId, { status: "running" });
|
|
205
|
+
try {
|
|
206
|
+
const client = await this.createA2AClient(agentRecord);
|
|
207
|
+
const result = await client.sendMessage({
|
|
208
|
+
message: {
|
|
209
|
+
kind: "message",
|
|
210
|
+
role: "user",
|
|
211
|
+
parts: [{ kind: "text", text: params.task }],
|
|
212
|
+
messageId: track.taskId,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return this.processTaskResult(track.taskId, result);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
219
|
+
this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
|
|
220
|
+
return { taskId: track.taskId, status: "failed", error: errorMsg };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
getTaskStatus(taskId) {
|
|
224
|
+
return this.taskTracker.get(taskId);
|
|
225
|
+
}
|
|
226
|
+
/* ---------------------------------------------------------------- */
|
|
227
|
+
/* Profile */
|
|
228
|
+
/* ---------------------------------------------------------------- */
|
|
229
|
+
async getProfile() {
|
|
230
|
+
return await this.profileStore.load();
|
|
231
|
+
}
|
|
232
|
+
async setProfile(patch) {
|
|
233
|
+
const profile = await this.profileStore.update(patch);
|
|
234
|
+
this.updateProfileDescription(profile);
|
|
235
|
+
await this.broadcastProfileToTeams();
|
|
236
|
+
return profile;
|
|
237
|
+
}
|
|
238
|
+
updateProfileDescription(profile) {
|
|
239
|
+
this.profileDescription = (0, agent_profile_1.renderProfileDescription)(profile);
|
|
240
|
+
if (this.agentCard) {
|
|
241
|
+
this.agentCard.description = this.profileDescription;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/* ---------------------------------------------------------------- */
|
|
245
|
+
/* Pending profile review (install / first-run) */
|
|
246
|
+
/* ---------------------------------------------------------------- */
|
|
247
|
+
getPendingReviewPath() {
|
|
248
|
+
return node_path_1.default.join(this.options.stateDir, "multiclaws", "pending-profile-review.json");
|
|
249
|
+
}
|
|
250
|
+
async getPendingProfileReview() {
|
|
251
|
+
const p = this.getPendingReviewPath();
|
|
252
|
+
const data = await (0, json_store_1.readJsonWithFallback)(p, {});
|
|
253
|
+
if (data.pending !== true) {
|
|
254
|
+
return { pending: false };
|
|
255
|
+
}
|
|
256
|
+
const profile = await this.profileStore.load();
|
|
257
|
+
return {
|
|
258
|
+
pending: true,
|
|
259
|
+
profile,
|
|
260
|
+
message: "这是您当前的 MultiClaws 档案,是否需要修改名字、角色、数据源或能力?",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
async setPendingProfileReview() {
|
|
264
|
+
const p = this.getPendingReviewPath();
|
|
265
|
+
await (0, json_store_1.writeJsonAtomically)(p, { pending: true });
|
|
266
|
+
}
|
|
267
|
+
async clearPendingProfileReview() {
|
|
268
|
+
const p = this.getPendingReviewPath();
|
|
269
|
+
try {
|
|
270
|
+
await promises_1.default.unlink(p);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// ignore if missing
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/* ---------------------------------------------------------------- */
|
|
277
|
+
/* Team management */
|
|
278
|
+
/* ---------------------------------------------------------------- */
|
|
279
|
+
async createTeam(name) {
|
|
280
|
+
const team = await this.teamStore.createTeam({
|
|
281
|
+
teamName: name,
|
|
282
|
+
selfUrl: this.selfUrl,
|
|
283
|
+
selfName: this.options.displayName ?? node_os_1.default.hostname(),
|
|
284
|
+
selfDescription: this.profileDescription,
|
|
285
|
+
});
|
|
286
|
+
this.log("info", `team created: ${team.teamId} (${team.teamName})`);
|
|
287
|
+
return team;
|
|
288
|
+
}
|
|
289
|
+
async createInvite(teamId) {
|
|
290
|
+
const team = teamId
|
|
291
|
+
? await this.teamStore.getTeam(teamId)
|
|
292
|
+
: await this.teamStore.getFirstTeam();
|
|
293
|
+
if (!team)
|
|
294
|
+
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
295
|
+
return (0, team_store_1.encodeInvite)(team.teamId, this.selfUrl);
|
|
296
|
+
}
|
|
297
|
+
async joinTeam(inviteCode) {
|
|
298
|
+
const invite = (0, team_store_1.decodeInvite)(inviteCode);
|
|
299
|
+
const seedUrl = invite.u.replace(/\/+$/, "");
|
|
300
|
+
// 1. Fetch member list from seed
|
|
301
|
+
let membersRes;
|
|
302
|
+
try {
|
|
303
|
+
membersRes = await fetch(`${seedUrl}/team/${invite.t}/members`);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
throw new Error(`Unable to reach team seed node at ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
307
|
+
}
|
|
308
|
+
if (!membersRes.ok) {
|
|
309
|
+
throw new Error(`failed to fetch team members from ${seedUrl}: HTTP ${membersRes.status}`);
|
|
310
|
+
}
|
|
311
|
+
const { team: remoteTeam } = (await membersRes.json());
|
|
312
|
+
// 2. Announce self to seed (seed broadcasts to others)
|
|
313
|
+
const selfMember = {
|
|
314
|
+
url: this.selfUrl,
|
|
315
|
+
name: this.options.displayName ?? node_os_1.default.hostname(),
|
|
316
|
+
description: this.profileDescription,
|
|
317
|
+
joinedAtMs: Date.now(),
|
|
318
|
+
};
|
|
319
|
+
let announceRes;
|
|
320
|
+
try {
|
|
321
|
+
announceRes = await fetch(`${seedUrl}/team/${invite.t}/announce`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Content-Type": "application/json" },
|
|
324
|
+
body: JSON.stringify(selfMember),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
throw new Error(`Failed to announce self to seed ${seedUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
329
|
+
}
|
|
330
|
+
if (!announceRes.ok) {
|
|
331
|
+
throw new Error(`failed to announce to seed ${seedUrl}: HTTP ${announceRes.status}`);
|
|
332
|
+
}
|
|
333
|
+
// 3. Store team locally
|
|
334
|
+
const allMembers = [...remoteTeam.members];
|
|
335
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
336
|
+
if (!allMembers.some((m) => m.url.replace(/\/+$/, "") === selfNormalized)) {
|
|
337
|
+
allMembers.push(selfMember);
|
|
338
|
+
}
|
|
339
|
+
const team = {
|
|
340
|
+
teamId: invite.t,
|
|
341
|
+
teamName: remoteTeam.teamName,
|
|
342
|
+
selfUrl: this.selfUrl,
|
|
343
|
+
members: allMembers,
|
|
344
|
+
createdAtMs: Date.now(),
|
|
345
|
+
};
|
|
346
|
+
await this.teamStore.saveTeam(team);
|
|
347
|
+
// 4. Fetch Agent Cards for members without descriptions, then sync to registry
|
|
348
|
+
await this.fetchMemberDescriptions(team);
|
|
349
|
+
await this.syncTeamToRegistry(team);
|
|
350
|
+
this.log("info", `joined team ${team.teamId} (${team.teamName}) with ${allMembers.length} members`);
|
|
351
|
+
return team;
|
|
352
|
+
}
|
|
353
|
+
async leaveTeam(teamId) {
|
|
354
|
+
const team = teamId
|
|
355
|
+
? await this.teamStore.getTeam(teamId)
|
|
356
|
+
: await this.teamStore.getFirstTeam();
|
|
357
|
+
if (!team)
|
|
358
|
+
throw new Error(teamId ? `team not found: ${teamId}` : "no team exists");
|
|
359
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
360
|
+
const selfMember = {
|
|
361
|
+
url: this.selfUrl,
|
|
362
|
+
name: this.options.displayName ?? node_os_1.default.hostname(),
|
|
363
|
+
joinedAtMs: 0,
|
|
364
|
+
};
|
|
365
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
366
|
+
await Promise.allSettled(others.map(async (m) => {
|
|
367
|
+
try {
|
|
368
|
+
await fetch(`${m.url}/team/${team.teamId}/leave`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: { "Content-Type": "application/json" },
|
|
371
|
+
body: JSON.stringify(selfMember),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
this.log("warn", `failed to notify ${m.url} about leaving`);
|
|
376
|
+
}
|
|
377
|
+
}));
|
|
378
|
+
for (const m of others) {
|
|
379
|
+
await this.agentRegistry.remove(m.url);
|
|
380
|
+
}
|
|
381
|
+
await this.teamStore.deleteTeam(team.teamId);
|
|
382
|
+
this.log("info", `left team ${team.teamId}`);
|
|
383
|
+
}
|
|
384
|
+
async listTeamMembers(teamId) {
|
|
385
|
+
const team = teamId
|
|
386
|
+
? await this.teamStore.getTeam(teamId)
|
|
387
|
+
: await this.teamStore.getFirstTeam();
|
|
388
|
+
if (!team)
|
|
389
|
+
return null;
|
|
390
|
+
return { team, members: team.members };
|
|
391
|
+
}
|
|
392
|
+
/* ---------------------------------------------------------------- */
|
|
393
|
+
/* Team REST routes */
|
|
394
|
+
/* ---------------------------------------------------------------- */
|
|
395
|
+
mountTeamRoutes(app) {
|
|
396
|
+
const announceBodySchema = zod_1.z.object({
|
|
397
|
+
url: zod_1.z.string().trim().min(1),
|
|
398
|
+
name: zod_1.z.string().trim().min(1),
|
|
399
|
+
description: zod_1.z.string().trim().optional(),
|
|
400
|
+
joinedAtMs: zod_1.z.number().optional(),
|
|
401
|
+
});
|
|
402
|
+
const leaveBodySchema = zod_1.z.object({
|
|
403
|
+
url: zod_1.z.string().trim().min(1),
|
|
404
|
+
});
|
|
405
|
+
const profileUpdateBodySchema = zod_1.z.object({
|
|
406
|
+
url: zod_1.z.string().trim().min(1),
|
|
407
|
+
name: zod_1.z.string().trim().optional(),
|
|
408
|
+
description: zod_1.z.string().optional(),
|
|
409
|
+
});
|
|
410
|
+
app.get("/team/:id/members", async (req, res) => {
|
|
411
|
+
try {
|
|
412
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
413
|
+
if (!team) {
|
|
414
|
+
res.status(404).json({ error: "team not found" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
res.json({ team: { teamName: team.teamName, members: team.members } });
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
res.status(500).json({ error: String(err) });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
app.post("/team/:id/announce", async (req, res) => {
|
|
424
|
+
try {
|
|
425
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
426
|
+
if (!team) {
|
|
427
|
+
res.status(404).json({ error: "team not found" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const parsed = announceBodySchema.safeParse(req.body);
|
|
431
|
+
if (!parsed.success) {
|
|
432
|
+
res.status(400).json({ error: parsed.error.message });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const member = parsed.data;
|
|
436
|
+
const normalizedUrl = member.url.replace(/\/+$/, "");
|
|
437
|
+
const alreadyKnown = team.members.some((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
438
|
+
await this.teamStore.addMember(team.teamId, {
|
|
439
|
+
url: normalizedUrl,
|
|
440
|
+
name: member.name,
|
|
441
|
+
description: member.description,
|
|
442
|
+
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
443
|
+
});
|
|
444
|
+
await this.agentRegistry.add({
|
|
445
|
+
url: normalizedUrl,
|
|
446
|
+
name: member.name,
|
|
447
|
+
description: member.description,
|
|
448
|
+
});
|
|
449
|
+
// Broadcast to other members if new
|
|
450
|
+
if (!alreadyKnown) {
|
|
451
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
452
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
|
|
453
|
+
m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
454
|
+
for (const other of others) {
|
|
455
|
+
void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { "Content-Type": "application/json" },
|
|
458
|
+
body: JSON.stringify({
|
|
459
|
+
url: normalizedUrl,
|
|
460
|
+
name: member.name,
|
|
461
|
+
description: member.description,
|
|
462
|
+
joinedAtMs: member.joinedAtMs ?? Date.now(),
|
|
463
|
+
}),
|
|
464
|
+
}).catch(() => {
|
|
465
|
+
this.log("warn", `broadcast to ${other.url} failed`);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
res.json({ ok: true });
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
res.status(500).json({ error: String(err) });
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
app.post("/team/:id/leave", async (req, res) => {
|
|
476
|
+
try {
|
|
477
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
478
|
+
if (!team) {
|
|
479
|
+
res.status(404).json({ error: "team not found" });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const parsed = leaveBodySchema.safeParse(req.body);
|
|
483
|
+
if (!parsed.success) {
|
|
484
|
+
res.status(400).json({ error: parsed.error.message });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
|
|
488
|
+
await this.teamStore.removeMember(team.teamId, normalizedUrl);
|
|
489
|
+
await this.agentRegistry.remove(normalizedUrl);
|
|
490
|
+
res.json({ ok: true });
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
res.status(500).json({ error: String(err) });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
// Profile update broadcast receiver
|
|
497
|
+
app.post("/team/:id/profile-update", async (req, res) => {
|
|
498
|
+
try {
|
|
499
|
+
const team = await this.teamStore.getTeam(req.params.id);
|
|
500
|
+
if (!team) {
|
|
501
|
+
res.status(404).json({ error: "team not found" });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const parsed = profileUpdateBodySchema.safeParse(req.body);
|
|
505
|
+
if (!parsed.success) {
|
|
506
|
+
res.status(400).json({ error: parsed.error.message });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const { url, name, description } = parsed.data;
|
|
510
|
+
const normalizedUrl = url.replace(/\/+$/, "");
|
|
511
|
+
// Update team member description
|
|
512
|
+
const existing = team.members.find((m) => m.url.replace(/\/+$/, "") === normalizedUrl);
|
|
513
|
+
if (existing) {
|
|
514
|
+
if (name)
|
|
515
|
+
existing.name = name;
|
|
516
|
+
if (description !== undefined)
|
|
517
|
+
existing.description = description;
|
|
518
|
+
await this.teamStore.saveTeam(team);
|
|
519
|
+
}
|
|
520
|
+
// Update agent registry description
|
|
521
|
+
if (description !== undefined) {
|
|
522
|
+
await this.agentRegistry.updateDescription(normalizedUrl, description);
|
|
523
|
+
}
|
|
524
|
+
res.json({ ok: true });
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
res.status(500).json({ error: String(err) });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/* ---------------------------------------------------------------- */
|
|
532
|
+
/* Private helpers */
|
|
533
|
+
/* ---------------------------------------------------------------- */
|
|
534
|
+
async broadcastProfileToTeams() {
|
|
535
|
+
const teams = await this.teamStore.listTeams();
|
|
536
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
537
|
+
const displayName = this.options.displayName ?? node_os_1.default.hostname();
|
|
538
|
+
for (const team of teams) {
|
|
539
|
+
// Update self in team store
|
|
540
|
+
await this.teamStore.addMember(team.teamId, {
|
|
541
|
+
url: this.selfUrl,
|
|
542
|
+
name: displayName,
|
|
543
|
+
description: this.profileDescription,
|
|
544
|
+
joinedAtMs: Date.now(),
|
|
545
|
+
});
|
|
546
|
+
// Broadcast to other members
|
|
547
|
+
const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
|
|
548
|
+
for (const member of others) {
|
|
549
|
+
void this.fetchWithRetry(`${member.url}/team/${team.teamId}/profile-update`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
url: this.selfUrl,
|
|
554
|
+
name: displayName,
|
|
555
|
+
description: this.profileDescription,
|
|
556
|
+
}),
|
|
557
|
+
}).catch(() => {
|
|
558
|
+
this.log("warn", `profile broadcast to ${member.url} failed`);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async fetchMemberDescriptions(team) {
|
|
564
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
565
|
+
await Promise.allSettled(team.members
|
|
566
|
+
.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized && !m.description)
|
|
567
|
+
.map(async (m) => {
|
|
568
|
+
try {
|
|
569
|
+
const client = await this.clientFactory.createFromUrl(m.url);
|
|
570
|
+
const card = await client.getAgentCard();
|
|
571
|
+
if (card.description) {
|
|
572
|
+
m.description = card.description;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
this.log("warn", `failed to fetch Agent Card from ${m.url}`);
|
|
577
|
+
}
|
|
578
|
+
}));
|
|
579
|
+
await this.teamStore.saveTeam(team);
|
|
580
|
+
}
|
|
581
|
+
async syncTeamToRegistry(team) {
|
|
582
|
+
const selfNormalized = this.selfUrl.replace(/\/+$/, "");
|
|
583
|
+
for (const member of team.members) {
|
|
584
|
+
if (member.url.replace(/\/+$/, "") === selfNormalized)
|
|
585
|
+
continue;
|
|
586
|
+
await this.agentRegistry.add({
|
|
587
|
+
url: member.url,
|
|
588
|
+
name: member.name,
|
|
589
|
+
description: member.description,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async createA2AClient(agent) {
|
|
594
|
+
return await this.clientFactory.createFromUrl(agent.url);
|
|
595
|
+
}
|
|
596
|
+
processTaskResult(trackId, result) {
|
|
597
|
+
if ("status" in result && result.status) {
|
|
598
|
+
const task = result;
|
|
599
|
+
const state = task.status?.state ?? "unknown";
|
|
600
|
+
const output = this.extractArtifactText(task);
|
|
601
|
+
if (state === "completed") {
|
|
602
|
+
this.taskTracker.update(trackId, { status: "completed", result: output });
|
|
603
|
+
}
|
|
604
|
+
else if (state === "failed") {
|
|
605
|
+
this.taskTracker.update(trackId, { status: "failed", error: output || "remote task failed" });
|
|
606
|
+
}
|
|
607
|
+
return { taskId: task.id, output, status: state };
|
|
608
|
+
}
|
|
609
|
+
const msg = result;
|
|
610
|
+
const text = msg.parts
|
|
611
|
+
?.filter((p) => p.kind === "text")
|
|
612
|
+
.map((p) => p.text)
|
|
613
|
+
.join("\n") ?? "";
|
|
614
|
+
this.taskTracker.update(trackId, { status: "completed", result: text });
|
|
615
|
+
return { taskId: trackId, output: text, status: "completed" };
|
|
616
|
+
}
|
|
617
|
+
extractArtifactText(task) {
|
|
618
|
+
if (!task.artifacts?.length)
|
|
619
|
+
return "";
|
|
620
|
+
return task.artifacts
|
|
621
|
+
.flatMap((a) => a.parts ?? [])
|
|
622
|
+
.filter((p) => p.kind === "text")
|
|
623
|
+
.map((p) => p.text)
|
|
624
|
+
.join("\n");
|
|
625
|
+
}
|
|
626
|
+
async notifyTailscaleSetup(tailscale) {
|
|
627
|
+
let message;
|
|
628
|
+
if (tailscale.status === "needs_auth") {
|
|
629
|
+
message = [
|
|
630
|
+
"🔗 **MultiClaws: Tailscale 登录**",
|
|
631
|
+
"",
|
|
632
|
+
"Tailscale 已安装但未登录,跨网络协作需要完成登录。",
|
|
633
|
+
"",
|
|
634
|
+
`👉 **请在浏览器打开:** ${tailscale.authUrl}`,
|
|
635
|
+
"",
|
|
636
|
+
"登录完成后重启 OpenClaw 即可。",
|
|
637
|
+
"_(局域网内协作无需此步骤,现在即可使用)_",
|
|
638
|
+
].join("\n");
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
// not_installed or unavailable
|
|
642
|
+
message = [
|
|
643
|
+
"🌐 **MultiClaws: 跨网络协作提示**",
|
|
644
|
+
"",
|
|
645
|
+
"**局域网内已可直接协作,无需任何配置。**",
|
|
646
|
+
"",
|
|
647
|
+
"如需跨网络(不同局域网间)协作,请安装 Tailscale:",
|
|
648
|
+
"https://tailscale.com/download",
|
|
649
|
+
"",
|
|
650
|
+
"安装并登录后重启 OpenClaw,将自动配置跨网络连接。",
|
|
651
|
+
].join("\n");
|
|
652
|
+
}
|
|
653
|
+
// Send to user via gateway (best-effort, don't throw)
|
|
654
|
+
if (this.options.gatewayConfig) {
|
|
655
|
+
try {
|
|
656
|
+
await (0, gateway_client_1.invokeGatewayTool)({
|
|
657
|
+
gateway: this.options.gatewayConfig,
|
|
658
|
+
tool: "message",
|
|
659
|
+
args: { action: "send", message },
|
|
660
|
+
timeoutMs: 5_000,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Fallback to log
|
|
665
|
+
this.log("warn", message.replace(/\*\*/g, "").replace(/```[^`]*```/gs, ""));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/** Fetch with up to 2 retries and exponential backoff. */
|
|
670
|
+
async fetchWithRetry(url, init, retries = 2) {
|
|
671
|
+
let lastError = null;
|
|
672
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
673
|
+
try {
|
|
674
|
+
const res = await fetch(url, init);
|
|
675
|
+
if (res.ok || attempt === retries)
|
|
676
|
+
return res;
|
|
677
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
681
|
+
if (attempt === retries)
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
685
|
+
}
|
|
686
|
+
throw lastError;
|
|
687
|
+
}
|
|
688
|
+
log(level, message) {
|
|
689
|
+
this.options.logger?.[level]?.(`[multiclaws] ${message}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
exports.MulticlawsService = MulticlawsService;
|
|
693
|
+
function getLocalIp() {
|
|
694
|
+
// Prefer Tailscale IP if available
|
|
695
|
+
const tsIp = (0, tailscale_1.getTailscaleIpFromInterfaces)();
|
|
696
|
+
if (tsIp)
|
|
697
|
+
return tsIp;
|
|
698
|
+
const interfaces = node_os_1.default.networkInterfaces();
|
|
699
|
+
for (const addrs of Object.values(interfaces)) {
|
|
700
|
+
if (!addrs)
|
|
701
|
+
continue;
|
|
702
|
+
for (const addr of addrs) {
|
|
703
|
+
if (addr.family === "IPv4" && !addr.internal)
|
|
704
|
+
return addr.address;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return node_os_1.default.hostname();
|
|
708
|
+
}
|