multiclaws 0.4.1 → 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
@@ -64,7 +64,10 @@ function createTools(getService) {
64
64
  throw new Error("url is required");
65
65
  const apiKey = typeof args.apiKey === "string" ? args.apiKey.trim() : undefined;
66
66
  const agent = await service.addAgent({ url, apiKey });
67
- return textResult(`Agent added: ${agent.name} (${agent.url})`, agent);
67
+ const status = agent.reachable
68
+ ? `Agent added: ${agent.name} (${agent.url})`
69
+ : `⚠️ Agent added but NOT reachable: ${agent.url} — agent card could not be fetched. Verify the URL and ensure the agent is running.`;
70
+ return textResult(status, agent);
68
71
  },
69
72
  };
70
73
  const multiclawsRemoveAgent = {
@@ -366,7 +369,7 @@ function createTools(getService) {
366
369
  const plugin = {
367
370
  id: "multiclaws",
368
371
  name: "MultiClaws",
369
- version: "0.3.1",
372
+ version: "0.4.2",
370
373
  register(api) {
371
374
  const config = readConfig(api);
372
375
  (0, telemetry_1.initializeTelemetry)({ enableConsoleExporter: config.telemetry?.consoleExporter });
@@ -379,7 +382,7 @@ const plugin = {
379
382
  if (gw) {
380
383
  const tools = (gw.tools ?? {});
381
384
  const allow = Array.isArray(tools.allow) ? tools.allow : [];
382
- const required = ["sessions_spawn", "sessions_history"];
385
+ const required = ["sessions_spawn", "sessions_history", "message"];
383
386
  const missing = required.filter((t) => !allow.includes(t));
384
387
  if (missing.length > 0) {
385
388
  tools.allow = [...allow, ...missing];
@@ -437,6 +440,17 @@ const plugin = {
437
440
  });
438
441
  api.on("gateway_start", () => {
439
442
  structured.logger.info("[multiclaws] gateway_start observed");
443
+ // Re-read gateway config in case token became available after initial registration
444
+ if (service && !gatewayConfig) {
445
+ const gw = api.config?.gateway;
446
+ const port = typeof gw?.port === "number" ? gw.port : 18789;
447
+ const token = typeof gw?.auth?.token === "string" ? gw.auth.token : null;
448
+ if (token) {
449
+ const newConfig = { port, token };
450
+ service.updateGatewayConfig(newConfig);
451
+ structured.logger.info("[multiclaws] gateway config updated from gateway_start event");
452
+ }
453
+ }
440
454
  });
441
455
  api.on("gateway_stop", () => {
442
456
  structured.logger.info("[multiclaws] gateway_stop observed");
@@ -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) => {
@@ -24,6 +24,7 @@ export declare class OpenClawAgentExecutor implements AgentExecutor {
24
24
  private gatewayConfig;
25
25
  private readonly taskTracker;
26
26
  private readonly logger;
27
+ private readonly a2aToTracker;
27
28
  constructor(options: A2AAdapterOptions);
28
29
  execute(context: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
29
30
  /**
@@ -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)
@@ -10,6 +11,34 @@ function extractTextFromMessage(message) {
10
11
  .map((p) => p.text)
11
12
  .join("\n");
12
13
  }
14
+ function buildTaskWithHistory(context) {
15
+ const currentText = extractTextFromMessage(context.userMessage);
16
+ const history = context.task?.history ?? [];
17
+ if (history.length <= 1) {
18
+ // First message — no prior context
19
+ return currentText;
20
+ }
21
+ // Build context from previous exchanges (exclude the last message, that's currentText)
22
+ const prior = history
23
+ .slice(0, -1)
24
+ .slice(-8) // keep last 8 messages max to avoid huge prompts
25
+ .map((m) => {
26
+ const text = extractTextFromMessage(m);
27
+ const role = m.role === "agent" ? "agent" : "user";
28
+ return `[${role}]: ${text}`;
29
+ })
30
+ .filter((line) => line.length > 10)
31
+ .join("\n");
32
+ if (!prior)
33
+ return currentText;
34
+ return [
35
+ "[conversation history]",
36
+ prior,
37
+ "",
38
+ "[latest message]",
39
+ currentText,
40
+ ].join("\n");
41
+ }
13
42
  function sleep(ms) {
14
43
  return new Promise((resolve) => setTimeout(resolve, ms));
15
44
  }
@@ -42,13 +71,15 @@ class OpenClawAgentExecutor {
42
71
  gatewayConfig;
43
72
  taskTracker;
44
73
  logger;
74
+ // Map A2A task IDs → internal tracker IDs so cancelTask can find the right record
75
+ a2aToTracker = new Map();
45
76
  constructor(options) {
46
77
  this.gatewayConfig = options.gatewayConfig;
47
78
  this.taskTracker = options.taskTracker;
48
79
  this.logger = options.logger;
49
80
  }
50
81
  async execute(context, eventBus) {
51
- const taskText = extractTextFromMessage(context.userMessage);
82
+ const taskText = buildTaskWithHistory(context);
52
83
  const taskId = context.taskId;
53
84
  if (!taskText.trim()) {
54
85
  this.publishMessage(eventBus, "Error: empty task received.");
@@ -56,14 +87,17 @@ class OpenClawAgentExecutor {
56
87
  return;
57
88
  }
58
89
  const fromAgent = context.userMessage.metadata?.agentUrl ?? "unknown";
59
- this.taskTracker.create({
90
+ const tracked = this.taskTracker.create({
60
91
  fromPeerId: fromAgent,
61
92
  toPeerId: "local",
62
93
  task: taskText,
63
94
  });
95
+ const trackedId = tracked.taskId;
96
+ this.a2aToTracker.set(taskId, trackedId);
64
97
  if (!this.gatewayConfig) {
65
98
  this.logger.error("[a2a-adapter] gateway config not available, cannot execute task");
66
- this.taskTracker.update(taskId, { status: "failed", error: "gateway config not available" });
99
+ this.taskTracker.update(trackedId, { status: "failed", error: "gateway config not available" });
100
+ this.a2aToTracker.delete(taskId);
67
101
  this.publishMessage(eventBus, "Error: gateway config not available, cannot execute task.");
68
102
  eventBus.finished();
69
103
  return;
@@ -90,16 +124,19 @@ class OpenClawAgentExecutor {
90
124
  this.logger.info(`[a2a-adapter] task ${taskId} spawned as ${childSessionKey}, waiting for result...`);
91
125
  const output = await this.waitForCompletion(childSessionKey, 180_000);
92
126
  // 3. Return result
93
- this.taskTracker.update(taskId, { status: "completed", result: output });
127
+ this.taskTracker.update(trackedId, { status: "completed", result: output });
94
128
  this.logger.info(`[a2a-adapter] task ${taskId} completed`);
95
129
  this.publishMessage(eventBus, output || "Task completed with no output.");
96
130
  }
97
131
  catch (err) {
98
132
  const errorMsg = err instanceof Error ? err.message : String(err);
99
133
  this.logger.error(`[a2a-adapter] task execution failed: ${errorMsg}`);
100
- this.taskTracker.update(taskId, { status: "failed", error: errorMsg });
134
+ this.taskTracker.update(trackedId, { status: "failed", error: errorMsg });
101
135
  this.publishMessage(eventBus, `Error: ${errorMsg}`);
102
136
  }
137
+ finally {
138
+ this.a2aToTracker.delete(taskId);
139
+ }
103
140
  eventBus.finished();
104
141
  }
105
142
  /**
@@ -155,14 +192,21 @@ class OpenClawAgentExecutor {
155
192
  const messages = (details.messages ?? []);
156
193
  if (messages.length === 0)
157
194
  return null;
158
- // If no explicit flag, check the last message for signs of ongoing execution
195
+ // If no explicit flag, use conservative heuristic: only consider
196
+ // complete if the last message is an assistant message with text
197
+ // and NO tool calls (tool calls indicate ongoing work)
159
198
  if (details.isComplete === undefined) {
160
199
  const lastMsg = messages[messages.length - 1];
161
- if (lastMsg && Array.isArray(lastMsg.content)) {
200
+ if (!lastMsg || lastMsg.role !== "assistant")
201
+ return null;
202
+ if (Array.isArray(lastMsg.content)) {
162
203
  const content = lastMsg.content;
163
204
  const hasToolCalls = content.some((c) => c?.type === "toolCall" || c?.type === "tool_use");
205
+ // If there are ANY tool calls, assume still running
206
+ if (hasToolCalls)
207
+ return null;
164
208
  const hasText = content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
165
- if (hasToolCalls && !hasText)
209
+ if (!hasText)
166
210
  return null;
167
211
  }
168
212
  }
@@ -188,7 +232,9 @@ class OpenClawAgentExecutor {
188
232
  return null;
189
233
  }
190
234
  async cancelTask(taskId, eventBus) {
191
- this.taskTracker.update(taskId, { status: "failed", error: "canceled" });
235
+ const trackedId = this.a2aToTracker.get(taskId) ?? taskId;
236
+ this.taskTracker.update(trackedId, { status: "failed", error: "canceled" });
237
+ this.a2aToTracker.delete(taskId);
192
238
  this.publishMessage(eventBus, "Task was canceled.");
193
239
  eventBus.finished();
194
240
  }
@@ -199,7 +245,7 @@ class OpenClawAgentExecutor {
199
245
  const message = {
200
246
  kind: "message",
201
247
  role: "agent",
202
- messageId: `msg-${Date.now()}`,
248
+ messageId: (0, node_crypto_1.randomUUID)(),
203
249
  parts: [{ kind: "text", text }],
204
250
  };
205
251
  eventBus.publish(message);
@@ -6,6 +6,8 @@ export type AgentRecord = {
6
6
  apiKey?: string;
7
7
  addedAtMs: number;
8
8
  lastSeenAtMs: number;
9
+ /** Which teams synced this agent. Empty or undefined = manually added. */
10
+ teamIds?: string[];
9
11
  };
10
12
  export declare class AgentRegistry {
11
13
  private readonly filePath;
@@ -21,6 +23,13 @@ export declare class AgentRegistry {
21
23
  remove(url: string): Promise<boolean>;
22
24
  list(): Promise<AgentRecord[]>;
23
25
  get(url: string): Promise<AgentRecord | null>;
26
+ addTeamSource(url: string, teamId: string): Promise<void>;
27
+ /**
28
+ * Remove a team source from an agent. Returns true if the agent
29
+ * was fully removed (no remaining sources), false otherwise.
30
+ * Manually-added agents (no teamIds) are never removed by this method.
31
+ */
32
+ removeTeamSource(url: string, teamId: string): Promise<boolean>;
24
33
  updateDescription(url: string, description: string): Promise<void>;
25
34
  updateLastSeen(url: string): Promise<void>;
26
35
  }
@@ -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;
@@ -73,6 +76,47 @@ class AgentRegistry {
73
76
  const normalizedUrl = url.replace(/\/+$/, "");
74
77
  return store.agents.find((a) => a.url === normalizedUrl) ?? null;
75
78
  }
79
+ async addTeamSource(url, teamId) {
80
+ await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
81
+ const store = await this.readStore();
82
+ const normalizedUrl = url.replace(/\/+$/, "");
83
+ const agent = store.agents.find((a) => a.url === normalizedUrl);
84
+ if (agent) {
85
+ const teams = new Set(agent.teamIds ?? []);
86
+ teams.add(teamId);
87
+ agent.teamIds = [...teams];
88
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
89
+ }
90
+ });
91
+ }
92
+ /**
93
+ * Remove a team source from an agent. Returns true if the agent
94
+ * was fully removed (no remaining sources), false otherwise.
95
+ * Manually-added agents (no teamIds) are never removed by this method.
96
+ */
97
+ async removeTeamSource(url, teamId) {
98
+ return await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
99
+ const store = await this.readStore();
100
+ const normalizedUrl = url.replace(/\/+$/, "");
101
+ const agent = store.agents.find((a) => a.url === normalizedUrl);
102
+ if (!agent)
103
+ return false;
104
+ // Agent was manually added (no team tracking) — never auto-remove
105
+ if (!agent.teamIds || agent.teamIds.length === 0)
106
+ return false;
107
+ const teams = new Set(agent.teamIds);
108
+ teams.delete(teamId);
109
+ if (teams.size === 0) {
110
+ // No team sources remain — remove entirely
111
+ store.agents = store.agents.filter((a) => a.url !== normalizedUrl);
112
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
113
+ return true;
114
+ }
115
+ agent.teamIds = [...teams];
116
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
117
+ return false;
118
+ });
119
+ }
76
120
  async updateDescription(url, description) {
77
121
  await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
78
122
  const store = await this.readStore();
@@ -36,6 +36,8 @@ export declare class MulticlawsService extends EventEmitter {
36
36
  private readonly profileStore;
37
37
  private readonly taskTracker;
38
38
  private readonly sessionStore;
39
+ private readonly sessionLocks;
40
+ private readonly sessionAborts;
39
41
  private agentExecutor;
40
42
  private a2aRequestHandler;
41
43
  private agentCard;
@@ -51,7 +53,9 @@ export declare class MulticlawsService extends EventEmitter {
51
53
  addAgent(params: {
52
54
  url: string;
53
55
  apiKey?: string;
54
- }): Promise<AgentRecord>;
56
+ }): Promise<AgentRecord & {
57
+ reachable: boolean;
58
+ }>;
55
59
  removeAgent(url: string): Promise<boolean>;
56
60
  startSession(params: {
57
61
  agentUrl: string;
@@ -77,7 +81,9 @@ export declare class MulticlawsService extends EventEmitter {
77
81
  timedOut: boolean;
78
82
  }>;
79
83
  endSession(sessionId: string): boolean;
84
+ private acquireSessionLock;
80
85
  private runSession;
86
+ private extractResultState;
81
87
  private handleSessionResult;
82
88
  private notifySessionUpdate;
83
89
  getProfile(): Promise<AgentProfile>;
@@ -37,6 +37,10 @@ class MulticlawsService extends node_events_1.EventEmitter {
37
37
  profileStore;
38
38
  taskTracker;
39
39
  sessionStore;
40
+ // Fix #5: per-session lock to prevent concurrent runSession calls
41
+ sessionLocks = new Map();
42
+ // Per-session AbortController so endSession can cancel in-flight runSession
43
+ sessionAborts = new Map();
40
44
  agentExecutor = null;
41
45
  a2aRequestHandler = null;
42
46
  agentCard = null;
@@ -106,7 +110,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
106
110
  name: this.options.displayName ?? (profile.ownerName || "OpenClaw Agent"),
107
111
  description: this.profileDescription,
108
112
  url: this.selfUrl,
109
- version: "0.3.0",
113
+ version: "0.4.2",
110
114
  protocolVersion: "0.2.2",
111
115
  defaultInputModes: ["text/plain"],
112
116
  defaultOutputModes: ["text/plain"],
@@ -145,7 +149,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
145
149
  }));
146
150
  const listenPort = this.options.port ?? 3100;
147
151
  this.httpServer = node_http_1.default.createServer(app);
148
- 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
+ });
149
159
  this.started = true;
150
160
  this.log("info", `multiclaws A2A service listening on :${listenPort}`);
151
161
  }
@@ -153,6 +163,18 @@ class MulticlawsService extends node_events_1.EventEmitter {
153
163
  if (!this.started)
154
164
  return;
155
165
  this.started = false;
166
+ // Abort all in-flight sessions so they don't hang
167
+ for (const [, abort] of this.sessionAborts) {
168
+ abort.abort();
169
+ }
170
+ // Wait for session locks to drain (with a cap)
171
+ if (this.sessionLocks.size > 0) {
172
+ const pending = [...this.sessionLocks.values()];
173
+ await Promise.race([
174
+ Promise.allSettled(pending),
175
+ new Promise((r) => setTimeout(r, 5_000)),
176
+ ]);
177
+ }
156
178
  this.taskTracker.destroy();
157
179
  this.httpRateLimiter.destroy();
158
180
  await new Promise((resolve) => {
@@ -165,6 +187,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
165
187
  this.httpServer = null;
166
188
  }
167
189
  updateGatewayConfig(config) {
190
+ this.options.gatewayConfig = config;
168
191
  this.agentExecutor?.updateGatewayConfig(config);
169
192
  }
170
193
  /* ---------------------------------------------------------------- */
@@ -178,20 +201,23 @@ class MulticlawsService extends node_events_1.EventEmitter {
178
201
  try {
179
202
  const client = await this.clientFactory.createFromUrl(normalizedUrl);
180
203
  const card = await client.getAgentCard();
181
- return await this.agentRegistry.add({
204
+ const record = await this.agentRegistry.add({
182
205
  url: normalizedUrl,
183
206
  name: card.name ?? normalizedUrl,
184
207
  description: card.description ?? "",
185
208
  skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
186
209
  apiKey: params.apiKey,
187
210
  });
211
+ return { ...record, reachable: true };
188
212
  }
189
213
  catch {
190
- return await this.agentRegistry.add({
214
+ this.log("warn", `agent at ${normalizedUrl} is not reachable, adding with limited info`);
215
+ const record = await this.agentRegistry.add({
191
216
  url: normalizedUrl,
192
217
  name: normalizedUrl,
193
218
  apiKey: params.apiKey,
194
219
  });
220
+ return { ...record, reachable: false };
195
221
  }
196
222
  }
197
223
  async removeAgent(url) {
@@ -205,27 +231,29 @@ class MulticlawsService extends node_events_1.EventEmitter {
205
231
  /* ---------------------------------------------------------------- */
206
232
  async startSession(params) {
207
233
  const agentRecord = await this.agentRegistry.get(params.agentUrl);
234
+ // Fix #3: throw instead of returning empty sessionId
208
235
  if (!agentRecord) {
209
- return { sessionId: "", status: "failed", error: `unknown agent: ${params.agentUrl}` };
236
+ throw new Error(`unknown agent: ${params.agentUrl}`);
210
237
  }
211
- const contextId = (0, node_crypto_1.randomUUID)();
238
+ // Fix #4: don't pre-generate contextId; let server assign it.
239
+ // Use a local placeholder that gets replaced after first response.
212
240
  const session = this.sessionStore.create({
213
241
  agentUrl: params.agentUrl,
214
242
  agentName: agentRecord.name,
215
- contextId,
243
+ contextId: "", // will be filled in from server response
216
244
  });
217
245
  this.sessionStore.appendMessage(session.sessionId, {
218
246
  role: "user",
219
247
  content: params.message,
220
248
  timestampMs: Date.now(),
221
249
  });
222
- void this.runSession({
250
+ void this.acquireSessionLock(session.sessionId, () => this.runSession({
223
251
  sessionId: session.sessionId,
224
252
  agentRecord,
225
253
  message: params.message,
226
- contextId,
254
+ contextId: undefined, // first message: no contextId
227
255
  taskId: undefined,
228
- });
256
+ }));
229
257
  return { sessionId: session.sessionId, status: "running" };
230
258
  }
231
259
  async sendSessionMessage(params) {
@@ -247,13 +275,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
247
275
  this.sessionStore.update(params.sessionId, { status: "failed", error: "agent no longer registered" });
248
276
  return { sessionId: params.sessionId, status: "failed", error: "agent no longer registered" };
249
277
  }
250
- void this.runSession({
278
+ // Fix #5: acquire lock to prevent concurrent runSession on same session
279
+ void this.acquireSessionLock(params.sessionId, () => this.runSession({
251
280
  sessionId: params.sessionId,
252
281
  agentRecord,
253
282
  message: params.message,
254
- contextId: session.contextId,
283
+ contextId: session.contextId || undefined,
255
284
  taskId: session.currentTaskId,
256
- });
285
+ }));
257
286
  return { sessionId: params.sessionId, status: "ok" };
258
287
  }
259
288
  getSession(sessionId) {
@@ -297,49 +326,117 @@ class MulticlawsService extends node_events_1.EventEmitter {
297
326
  if (!session)
298
327
  return false;
299
328
  this.sessionStore.update(sessionId, { status: "canceled" });
329
+ // Signal the in-flight runSession to abort
330
+ const abort = this.sessionAborts.get(sessionId);
331
+ if (abort)
332
+ abort.abort();
300
333
  return true;
301
334
  }
335
+ // Fix #5: serialise concurrent calls on the same session
336
+ async acquireSessionLock(sessionId, fn) {
337
+ const prev = this.sessionLocks.get(sessionId) ?? Promise.resolve();
338
+ let release;
339
+ const next = new Promise((r) => { release = r; });
340
+ this.sessionLocks.set(sessionId, next);
341
+ try {
342
+ await prev;
343
+ await fn();
344
+ }
345
+ finally {
346
+ release();
347
+ if (this.sessionLocks.get(sessionId) === next) {
348
+ this.sessionLocks.delete(sessionId);
349
+ }
350
+ }
351
+ }
302
352
  async runSession(params) {
303
- const timeout = params.timeoutMs ?? 5 * 60 * 1000; // 5 min default
304
- const timeoutController = new AbortController();
305
- const timer = setTimeout(() => timeoutController.abort(), timeout);
353
+ const timeout = params.timeoutMs ?? 5 * 60 * 1000;
354
+ const deadline = Date.now() + timeout;
355
+ const abortController = new AbortController();
356
+ this.sessionAborts.set(params.sessionId, abortController);
357
+ const timer = setTimeout(() => abortController.abort(), timeout);
306
358
  try {
307
359
  const client = await this.createA2AClient(params.agentRecord);
308
- const result = await Promise.race([
309
- client.sendMessage({
310
- message: {
311
- kind: "message",
312
- role: "user",
313
- parts: [{ kind: "text", text: params.message }],
314
- messageId: (0, node_crypto_1.randomUUID)(),
315
- contextId: params.contextId,
316
- ...(params.taskId ? { taskId: params.taskId } : {}),
317
- },
318
- }),
319
- new Promise((_, reject) => timeoutController.signal.addEventListener("abort", () => reject(new Error("session timeout")))),
320
- ]);
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
+ };
374
+ let result = await withAbort(client.sendMessage({
375
+ message: {
376
+ kind: "message",
377
+ role: "user",
378
+ parts: [{ kind: "text", text: params.message }],
379
+ messageId: (0, node_crypto_1.randomUUID)(),
380
+ // Fix #4: only pass contextId if we have a server-assigned one
381
+ ...(params.contextId ? { contextId: params.contextId } : {}),
382
+ ...(params.taskId ? { taskId: params.taskId } : {}),
383
+ },
384
+ }));
385
+ // Fix #1: poll until terminal state if server returns working/submitted
386
+ const POLL_DELAYS = [1000, 2000, 3000, 5000];
387
+ let pollAttempt = 0;
388
+ while (true) {
389
+ const state = this.extractResultState(result);
390
+ const remoteTaskId = "id" in result ? result.id : undefined;
391
+ if (state !== "working" && state !== "submitted")
392
+ break;
393
+ if (!remoteTaskId)
394
+ break; // can't poll without task id
395
+ if (Date.now() >= deadline)
396
+ throw new Error("session timeout");
397
+ const delay = POLL_DELAYS[Math.min(pollAttempt, POLL_DELAYS.length - 1)];
398
+ await new Promise((r) => setTimeout(r, delay));
399
+ pollAttempt++;
400
+ result = await withAbort(client.getTask({ id: remoteTaskId, historyLength: 10 }));
401
+ }
402
+ // Check if session was canceled while we were running
403
+ const current = this.sessionStore.get(params.sessionId);
404
+ if (current?.status === "canceled")
405
+ return;
321
406
  await this.handleSessionResult(params.sessionId, result);
322
407
  }
323
408
  catch (err) {
409
+ // Don't overwrite a user-initiated cancel
410
+ const current = this.sessionStore.get(params.sessionId);
411
+ if (current?.status === "canceled")
412
+ return;
324
413
  const errorMsg = err instanceof Error ? err.message : String(err);
325
414
  this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
326
415
  await this.notifySessionUpdate(params.sessionId, "failed");
327
416
  }
328
417
  finally {
329
418
  clearTimeout(timer);
419
+ this.sessionAborts.delete(params.sessionId);
330
420
  }
331
421
  }
422
+ extractResultState(result) {
423
+ if ("status" in result && result.status) {
424
+ return result.status?.state ?? "unknown";
425
+ }
426
+ return "completed"; // plain Message = completed
427
+ }
332
428
  async handleSessionResult(sessionId, result) {
333
- // Extract content
334
429
  let content = "";
335
430
  let state = "completed";
336
431
  let remoteTaskId;
432
+ let serverContextId;
337
433
  if ("status" in result && result.status) {
338
434
  const task = result;
339
435
  state = task.status?.state ?? "completed";
340
436
  remoteTaskId = task.id;
437
+ // Fix #4: capture server-assigned contextId
438
+ serverContextId = task.contextId;
341
439
  content = this.extractArtifactText(task);
342
- // Also try to get text from task messages if artifacts empty
343
440
  if (!content && task.history?.length) {
344
441
  const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
345
442
  if (lastAgentMsg) {
@@ -353,23 +450,24 @@ class MulticlawsService extends node_events_1.EventEmitter {
353
450
  else {
354
451
  const msg = result;
355
452
  remoteTaskId = msg.taskId;
453
+ serverContextId = msg.contextId;
356
454
  content = msg.parts
357
455
  ?.filter((p) => p.kind === "text")
358
456
  .map((p) => p.text)
359
457
  .join("\n") ?? "";
360
458
  }
361
- // Append agent message to history
362
- if (content) {
363
- this.sessionStore.appendMessage(sessionId, {
364
- role: "agent",
365
- content,
366
- timestampMs: Date.now(),
367
- taskId: remoteTaskId,
368
- });
369
- }
370
- // Update session state
459
+ // Fix #6: always record agent message, use placeholder when content is empty
460
+ this.sessionStore.appendMessage(sessionId, {
461
+ role: "agent",
462
+ content: content || "(no text output)",
463
+ timestampMs: Date.now(),
464
+ taskId: remoteTaskId,
465
+ });
466
+ // Fix #4: update contextId with server-assigned value
467
+ const contextUpdate = serverContextId ? { contextId: serverContextId } : {};
371
468
  if (state === "input-required" || state === "auth-required") {
372
469
  this.sessionStore.update(sessionId, {
470
+ ...contextUpdate,
373
471
  status: "input-required",
374
472
  currentTaskId: remoteTaskId,
375
473
  });
@@ -377,36 +475,54 @@ class MulticlawsService extends node_events_1.EventEmitter {
377
475
  }
378
476
  else if (state === "failed" || state === "rejected") {
379
477
  this.sessionStore.update(sessionId, {
478
+ ...contextUpdate,
380
479
  status: "failed",
381
480
  currentTaskId: remoteTaskId,
382
481
  error: content || "remote task failed",
383
482
  });
384
483
  await this.notifySessionUpdate(sessionId, "failed");
385
484
  }
386
- else if (state === "completed" || state === "canceled") {
485
+ else if (state === "completed") {
387
486
  this.sessionStore.update(sessionId, {
487
+ ...contextUpdate,
388
488
  status: "completed",
389
489
  currentTaskId: remoteTaskId,
390
490
  });
391
491
  await this.notifySessionUpdate(sessionId, "completed");
392
492
  }
493
+ else if (state === "canceled") {
494
+ // Fix #2: canceled remote task → local status "canceled", not "completed"
495
+ this.sessionStore.update(sessionId, {
496
+ ...contextUpdate,
497
+ status: "canceled",
498
+ currentTaskId: remoteTaskId,
499
+ error: "remote task was canceled",
500
+ });
501
+ await this.notifySessionUpdate(sessionId, "failed");
502
+ }
393
503
  else {
394
- // working / submitted / unknown still in progress, no notification
395
- this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
504
+ // working / submitted / unknown: runSession's polling loop handles these
505
+ this.sessionStore.update(sessionId, { ...contextUpdate, currentTaskId: remoteTaskId });
396
506
  }
397
507
  }
398
508
  async notifySessionUpdate(sessionId, event) {
399
- if (!this.options.gatewayConfig)
509
+ if (!this.options.gatewayConfig) {
510
+ this.log("warn", `session ${sessionId} ${event} but gateway config unavailable — user won't be notified. Check gateway.auth.token in config.`);
400
511
  return;
512
+ }
401
513
  const session = this.sessionStore.get(sessionId);
402
514
  if (!session)
403
515
  return;
404
516
  const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
405
- const content = lastAgentMsg?.content ?? "";
517
+ const rawContent = lastAgentMsg?.content ?? "";
518
+ // Don't show the placeholder text in user-facing notifications
519
+ const content = rawContent === "(no text output)" ? "" : rawContent;
406
520
  const agentName = session.agentName;
407
521
  let message;
408
522
  if (event === "completed") {
409
- message = [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n");
523
+ message = content
524
+ ? [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n")
525
+ : `✅ **${agentName} 任务完成** (session: \`${sessionId}\`) — 任务已执行但无文本输出,可能产生了 artifacts。`;
410
526
  }
411
527
  else if (event === "input-required") {
412
528
  message = [
@@ -586,7 +702,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
586
702
  }
587
703
  }));
588
704
  for (const m of others) {
589
- await this.agentRegistry.remove(m.url);
705
+ await this.agentRegistry.removeTeamSource(m.url, team.teamId);
590
706
  }
591
707
  await this.teamStore.deleteTeam(team.teamId);
592
708
  this.log("info", `left team ${team.teamId}`);
@@ -662,10 +778,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
662
778
  name: member.name,
663
779
  description: member.description,
664
780
  });
781
+ await this.agentRegistry.addTeamSource(normalizedUrl, team.teamId);
665
782
  // Broadcast to other members if new
666
783
  if (!alreadyKnown) {
667
784
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
668
- const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
785
+ // Re-read team after addMember to get the latest member list,
786
+ // avoiding missed broadcasts when multiple members join concurrently
787
+ const freshTeam = await this.teamStore.getTeam(team.teamId);
788
+ const others = (freshTeam?.members ?? team.members).filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
669
789
  m.url.replace(/\/+$/, "") !== selfNormalized);
670
790
  for (const other of others) {
671
791
  void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
@@ -702,7 +822,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
702
822
  }
703
823
  const normalizedUrl = parsed.data.url.replace(/\/+$/, "");
704
824
  await this.teamStore.removeMember(team.teamId, normalizedUrl);
705
- await this.agentRegistry.remove(normalizedUrl);
825
+ await this.agentRegistry.removeTeamSource(normalizedUrl, team.teamId);
706
826
  res.json({ ok: true });
707
827
  }
708
828
  catch (err) {
@@ -752,12 +872,13 @@ class MulticlawsService extends node_events_1.EventEmitter {
752
872
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
753
873
  const displayName = this.options.displayName ?? node_os_1.default.hostname();
754
874
  for (const team of teams) {
755
- // 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);
756
877
  await this.teamStore.addMember(team.teamId, {
757
878
  url: this.selfUrl,
758
879
  name: displayName,
759
880
  description: this.profileDescription,
760
- joinedAtMs: Date.now(),
881
+ joinedAtMs: selfMember?.joinedAtMs ?? Date.now(),
761
882
  });
762
883
  // Broadcast to other members
763
884
  const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== selfNormalized);
@@ -785,14 +906,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
785
906
  const client = await this.clientFactory.createFromUrl(m.url);
786
907
  const card = await client.getAgentCard();
787
908
  if (card.description) {
788
- m.description = card.description;
909
+ // Use addMember (which uses withJsonLock) instead of saveTeam
910
+ // to avoid overwriting concurrent member additions
911
+ await this.teamStore.addMember(team.teamId, {
912
+ url: m.url,
913
+ name: m.name,
914
+ description: card.description,
915
+ joinedAtMs: m.joinedAtMs,
916
+ });
789
917
  }
790
918
  }
791
919
  catch {
792
920
  this.log("warn", `failed to fetch Agent Card from ${m.url}`);
793
921
  }
794
922
  }));
795
- await this.teamStore.saveTeam(team);
796
923
  }
797
924
  async syncTeamToRegistry(team) {
798
925
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
@@ -804,6 +931,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
804
931
  name: member.name,
805
932
  description: member.description,
806
933
  });
934
+ await this.agentRegistry.addTeamSource(member.url, team.teamId);
807
935
  }
808
936
  }
809
937
  async createA2AClient(agent) {
@@ -10,9 +10,24 @@ const promises_1 = __importDefault(require("node:fs/promises"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
12
12
  const MAX_SESSIONS = 1_000;
13
+ const MAX_MESSAGES_PER_SESSION = 200;
13
14
  function emptyStore() {
14
15
  return { version: 1, sessions: [] };
15
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
+ }
16
31
  class SessionStore {
17
32
  filePath;
18
33
  ttlMs;
@@ -66,17 +81,18 @@ class SessionStore {
66
81
  const session = this.get(sessionId);
67
82
  if (!session)
68
83
  return null;
69
- return this.update(sessionId, {
70
- messages: [...session.messages, msg],
71
- });
84
+ let messages = [...session.messages, msg];
85
+ // Truncate old messages, keeping the most recent ones
86
+ if (messages.length > MAX_MESSAGES_PER_SESSION) {
87
+ messages = messages.slice(-MAX_MESSAGES_PER_SESSION);
88
+ }
89
+ return this.update(sessionId, { messages });
72
90
  }
73
91
  loadSync() {
74
92
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
75
93
  try {
76
94
  const raw = JSON.parse(node_fs_1.default.readFileSync(this.filePath, "utf8"));
77
- if (raw.version !== 1 || !Array.isArray(raw.sessions))
78
- return emptyStore();
79
- return raw;
95
+ return normalizeStore(raw);
80
96
  }
81
97
  catch {
82
98
  const store = emptyStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.4.1",
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",