multiclaws 0.4.2 → 0.4.3

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.
@@ -40,9 +40,14 @@ function createGatewayHandlers(getService) {
40
40
  const handlers = {
41
41
  /* ── Agent handlers ─────────────────────────────────────────── */
42
42
  "multiclaws.agent.list": async ({ respond }) => {
43
- const service = getService();
44
- const agents = await service.listAgents();
45
- respond(true, { agents });
43
+ try {
44
+ const service = getService();
45
+ const agents = await service.listAgents();
46
+ respond(true, { agents });
47
+ }
48
+ catch (error) {
49
+ safeHandle(respond, "agent_list_failed", error);
50
+ }
46
51
  },
47
52
  "multiclaws.agent.add": async ({ params, respond }) => {
48
53
  try {
@@ -187,19 +192,34 @@ function createGatewayHandlers(getService) {
187
192
  },
188
193
  /* ── Profile handlers ───────────────────────────────────────── */
189
194
  "multiclaws.profile.show": async ({ respond }) => {
190
- const service = getService();
191
- const profile = await service.getProfile();
192
- respond(true, profile);
195
+ try {
196
+ const service = getService();
197
+ const profile = await service.getProfile();
198
+ respond(true, profile);
199
+ }
200
+ catch (error) {
201
+ safeHandle(respond, "profile_show_failed", error);
202
+ }
193
203
  },
194
204
  "multiclaws.profile.pending_review": async ({ respond }) => {
195
- const service = getService();
196
- const result = await service.getPendingProfileReview();
197
- respond(true, result);
205
+ try {
206
+ const service = getService();
207
+ const result = await service.getPendingProfileReview();
208
+ respond(true, result);
209
+ }
210
+ catch (error) {
211
+ safeHandle(respond, "profile_pending_review_failed", error);
212
+ }
198
213
  },
199
214
  "multiclaws.profile.clear_pending_review": async ({ respond }) => {
200
- const service = getService();
201
- await service.clearPendingProfileReview();
202
- respond(true, { cleared: true });
215
+ try {
216
+ const service = getService();
217
+ await service.clearPendingProfileReview();
218
+ respond(true, { cleared: true });
219
+ }
220
+ catch (error) {
221
+ safeHandle(respond, "profile_clear_pending_review_failed", error);
222
+ }
203
223
  },
204
224
  "multiclaws.profile.set": async ({ params, respond }) => {
205
225
  try {
package/dist/index.js CHANGED
@@ -369,7 +369,7 @@ function createTools(getService) {
369
369
  const plugin = {
370
370
  id: "multiclaws",
371
371
  name: "MultiClaws",
372
- version: "0.3.1",
372
+ version: "0.4.2",
373
373
  register(api) {
374
374
  const config = readConfig(api);
375
375
  (0, telemetry_1.initializeTelemetry)({ enableConsoleExporter: config.telemetry?.consoleExporter });
@@ -382,7 +382,7 @@ const plugin = {
382
382
  if (gw) {
383
383
  const tools = (gw.tools ?? {});
384
384
  const allow = Array.isArray(tools.allow) ? tools.allow : [];
385
- const required = ["sessions_spawn", "sessions_history"];
385
+ const required = ["sessions_spawn", "sessions_history", "message"];
386
386
  const missing = required.filter((t) => !allow.includes(t));
387
387
  if (missing.length > 0) {
388
388
  tools.allow = [...allow, ...missing];
@@ -40,6 +40,7 @@ exports.invokeGatewayTool = invokeGatewayTool;
40
40
  const opossum_1 = __importDefault(require("opossum"));
41
41
  class NonRetryableError extends Error {
42
42
  }
43
+ const MAX_BREAKERS = 50;
43
44
  const breakerCache = new Map();
44
45
  let pRetryModulePromise = null;
45
46
  async function loadPRetry() {
@@ -53,6 +54,15 @@ function getBreaker(key, timeoutMs) {
53
54
  if (existing) {
54
55
  return existing;
55
56
  }
57
+ // Evict oldest entries when cache is full
58
+ if (breakerCache.size >= MAX_BREAKERS) {
59
+ const oldest = breakerCache.keys().next().value;
60
+ if (oldest !== undefined) {
61
+ const old = breakerCache.get(oldest);
62
+ old?.shutdown();
63
+ breakerCache.delete(oldest);
64
+ }
65
+ }
56
66
  const breaker = new opossum_1.default((operation) => operation(), {
57
67
  timeout: false, // timeout handled by AbortController in the operation
58
68
  errorThresholdPercentage: 50,
@@ -10,7 +10,7 @@ export type TailscaleStatus = {
10
10
  status: "unavailable";
11
11
  reason: string;
12
12
  };
13
- /** Check network interfaces for a Tailscale IP (100.x.x.x) — exported for fast-path checks */
13
+ /** Check network interfaces for a Tailscale IP (100.64.0.0/10) — exported for fast-path checks */
14
14
  export declare function getTailscaleIpFromInterfaces(): string | null;
15
15
  /**
16
16
  * Detect Tailscale status — does NOT install or modify system state.
@@ -7,6 +7,7 @@ exports.getTailscaleIpFromInterfaces = getTailscaleIpFromInterfaces;
7
7
  exports.detectTailscale = detectTailscale;
8
8
  const node_child_process_1 = require("node:child_process");
9
9
  const node_os_1 = __importDefault(require("node:os"));
10
+ const isWindows = process.platform === "win32";
10
11
  function run(cmd, timeoutMs = 5_000) {
11
12
  return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
12
13
  .toString()
@@ -14,21 +15,30 @@ function run(cmd, timeoutMs = 5_000) {
14
15
  }
15
16
  function commandExists(cmd) {
16
17
  try {
17
- run(`which ${cmd}`);
18
+ run(isWindows ? `where ${cmd}` : `which ${cmd}`);
18
19
  return true;
19
20
  }
20
21
  catch {
21
22
  return false;
22
23
  }
23
24
  }
24
- /** Check network interfaces for a Tailscale IP (100.x.x.x) — exported for fast-path checks */
25
+ /** Check whether an IPv4 address falls within the Tailscale CGNAT range (100.64.0.0/10). */
26
+ function isTailscaleCGNAT(ip) {
27
+ const parts = ip.split(".");
28
+ if (parts.length !== 4)
29
+ return false;
30
+ const first = parseInt(parts[0], 10);
31
+ const second = parseInt(parts[1], 10);
32
+ return first === 100 && second >= 64 && second <= 127;
33
+ }
34
+ /** Check network interfaces for a Tailscale IP (100.64.0.0/10) — exported for fast-path checks */
25
35
  function getTailscaleIpFromInterfaces() {
26
36
  const interfaces = node_os_1.default.networkInterfaces();
27
37
  for (const addrs of Object.values(interfaces)) {
28
38
  if (!addrs)
29
39
  continue;
30
40
  for (const addr of addrs) {
31
- if (addr.family === "IPv4" && addr.address.startsWith("100.")) {
41
+ if (addr.family === "IPv4" && isTailscaleCGNAT(addr.address)) {
32
42
  return addr.address;
33
43
  }
34
44
  }
@@ -61,8 +71,7 @@ async function getAuthUrl() {
61
71
  return new Promise((resolve) => {
62
72
  try {
63
73
  // tailscale up prints the auth URL to stderr
64
- const { spawn } = require("node:child_process");
65
- const proc = spawn("tailscale", ["up"], { stdio: ["ignore", "pipe", "pipe"] });
74
+ const proc = (0, node_child_process_1.spawn)("tailscale", ["up"], { stdio: ["ignore", "pipe", "pipe"] });
66
75
  let output = "";
67
76
  let resolved = false;
68
77
  const tryResolve = (text) => {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenClawAgentExecutor = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const gateway_client_1 = require("../infra/gateway-client");
5
6
  function extractTextFromMessage(message) {
6
7
  if (!message.parts)
@@ -23,7 +24,7 @@ function buildTaskWithHistory(context) {
23
24
  .slice(-8) // keep last 8 messages max to avoid huge prompts
24
25
  .map((m) => {
25
26
  const text = extractTextFromMessage(m);
26
- const role = m.role === "agent" ? "[agent]" : "[user]";
27
+ const role = m.role === "agent" ? "agent" : "user";
27
28
  return `[${role}]: ${text}`;
28
29
  })
29
30
  .filter((line) => line.length > 10)
@@ -96,6 +97,7 @@ class OpenClawAgentExecutor {
96
97
  if (!this.gatewayConfig) {
97
98
  this.logger.error("[a2a-adapter] gateway config not available, cannot execute task");
98
99
  this.taskTracker.update(trackedId, { status: "failed", error: "gateway config not available" });
100
+ this.a2aToTracker.delete(taskId);
99
101
  this.publishMessage(eventBus, "Error: gateway config not available, cannot execute task.");
100
102
  eventBus.finished();
101
103
  return;
@@ -243,7 +245,7 @@ class OpenClawAgentExecutor {
243
245
  const message = {
244
246
  kind: "message",
245
247
  role: "agent",
246
- messageId: `msg-${Date.now()}`,
248
+ messageId: (0, node_crypto_1.randomUUID)(),
247
249
  parts: [{ kind: "text", text }],
248
250
  };
249
251
  eventBus.publish(message);
@@ -32,14 +32,17 @@ class AgentRegistry {
32
32
  const normalizedUrl = params.url.replace(/\/+$/, "");
33
33
  const existing = store.agents.findIndex((a) => a.url === normalizedUrl);
34
34
  const now = Date.now();
35
+ const prev = existing >= 0 ? store.agents[existing] : null;
35
36
  const record = {
36
37
  url: normalizedUrl,
37
38
  name: params.name,
38
39
  description: params.description ?? "",
39
40
  skills: params.skills ?? [],
40
41
  apiKey: params.apiKey,
41
- addedAtMs: existing >= 0 ? store.agents[existing].addedAtMs : now,
42
+ addedAtMs: prev?.addedAtMs ?? now,
42
43
  lastSeenAtMs: now,
44
+ // Preserve existing teamIds so that team associations are not lost on upsert
45
+ ...(prev?.teamIds?.length ? { teamIds: prev.teamIds } : {}),
43
46
  };
44
47
  if (existing >= 0) {
45
48
  store.agents[existing] = record;
@@ -110,7 +110,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
110
110
  name: this.options.displayName ?? (profile.ownerName || "OpenClaw Agent"),
111
111
  description: this.profileDescription,
112
112
  url: this.selfUrl,
113
- version: "0.3.0",
113
+ version: "0.4.2",
114
114
  protocolVersion: "0.2.2",
115
115
  defaultInputModes: ["text/plain"],
116
116
  defaultOutputModes: ["text/plain"],
@@ -149,7 +149,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
149
149
  }));
150
150
  const listenPort = this.options.port ?? 3100;
151
151
  this.httpServer = node_http_1.default.createServer(app);
152
- await new Promise((resolve) => this.httpServer.listen(listenPort, "0.0.0.0", resolve));
152
+ await new Promise((resolve, reject) => {
153
+ this.httpServer.once("error", reject);
154
+ this.httpServer.listen(listenPort, "0.0.0.0", () => {
155
+ this.httpServer.removeListener("error", reject);
156
+ resolve();
157
+ });
158
+ });
153
159
  this.started = true;
154
160
  this.log("info", `multiclaws A2A service listening on :${listenPort}`);
155
161
  }
@@ -351,18 +357,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
351
357
  const timer = setTimeout(() => abortController.abort(), timeout);
352
358
  try {
353
359
  const client = await this.createA2AClient(params.agentRecord);
354
- const withAbort = (p) => Promise.race([
355
- p,
356
- new Promise((_, reject) => {
357
- if (abortController.signal.aborted) {
358
- reject(new Error("session canceled"));
359
- return;
360
- }
361
- abortController.signal.addEventListener("abort", () => reject(new Error(this.sessionStore.get(params.sessionId)?.status === "canceled"
362
- ? "session canceled"
363
- : "session timeout")));
364
- }),
365
- ]);
360
+ const withAbort = (p) => {
361
+ if (abortController.signal.aborted) {
362
+ return Promise.reject(new Error("session canceled"));
363
+ }
364
+ return new Promise((resolve, reject) => {
365
+ const onAbort = () => {
366
+ reject(new Error(this.sessionStore.get(params.sessionId)?.status === "canceled"
367
+ ? "session canceled"
368
+ : "session timeout"));
369
+ };
370
+ abortController.signal.addEventListener("abort", onAbort, { once: true });
371
+ p.then((val) => { abortController.signal.removeEventListener("abort", onAbort); resolve(val); }, (err) => { abortController.signal.removeEventListener("abort", onAbort); reject(err); });
372
+ });
373
+ };
366
374
  let result = await withAbort(client.sendMessage({
367
375
  message: {
368
376
  kind: "message",
@@ -814,7 +822,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
814
822
  }
815
823
  const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
816
824
  await this.teamStore.removeMember(team.teamId, normalizedUrl);
817
- await this.agentRegistry.remove(normalizedUrl);
825
+ await this.agentRegistry.removeTeamSource(normalizedUrl, team.teamId);
818
826
  res.json({ ok: true });
819
827
  }
820
828
  catch (err) {
@@ -864,12 +872,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
864
872
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
865
873
  const displayName = this.options.displayName ?? node_os_1.default.hostname();
866
874
  for (const team of teams) {
867
- // Update self in team store
875
+ // Update self in team store, preserving original joinedAtMs
876
+ const selfMember = team.members.find((m) => m.url.replace(/\/+$/, "") === selfNormalized);
868
877
  await this.teamStore.addMember(team.teamId, {
869
878
  url: this.selfUrl,
870
879
  name: displayName,
871
880
  description: this.profileDescription,
872
- joinedAtMs: Date.now(),
881
+ joinedAtMs: selfMember?.joinedAtMs ?? Date.now(),
873
882
  });
874
883
  // Broadcast to other members
875
884
  const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
@@ -14,6 +14,20 @@ const MAX_MESSAGES_PER_SESSION = 200;
14
14
  function emptyStore() {
15
15
  return { version: 1, sessions: [] };
16
16
  }
17
+ function normalizeStore(raw) {
18
+ if (raw.version !== 1 || !Array.isArray(raw.sessions)) {
19
+ return emptyStore();
20
+ }
21
+ return {
22
+ version: 1,
23
+ sessions: raw.sessions.filter((s) => s &&
24
+ typeof s.sessionId === "string" &&
25
+ typeof s.agentUrl === "string" &&
26
+ typeof s.status === "string" &&
27
+ typeof s.createdAtMs === "number" &&
28
+ Array.isArray(s.messages)),
29
+ };
30
+ }
17
31
  class SessionStore {
18
32
  filePath;
19
33
  ttlMs;
@@ -78,9 +92,7 @@ class SessionStore {
78
92
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
79
93
  try {
80
94
  const raw = JSON.parse(node_fs_1.default.readFileSync(this.filePath, "utf8"));
81
- if (raw.version !== 1 || !Array.isArray(raw.sessions))
82
- return emptyStore();
83
- return raw;
95
+ return normalizeStore(raw);
84
96
  }
85
97
  catch {
86
98
  const store = emptyStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "MultiClaws plugin for OpenClaw collaboration via A2A protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",