multiclaws 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 = {
@@ -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");
@@ -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
  /**
@@ -10,6 +10,34 @@ function extractTextFromMessage(message) {
10
10
  .map((p) => p.text)
11
11
  .join("\n");
12
12
  }
13
+ function buildTaskWithHistory(context) {
14
+ const currentText = extractTextFromMessage(context.userMessage);
15
+ const history = context.task?.history ?? [];
16
+ if (history.length <= 1) {
17
+ // First message — no prior context
18
+ return currentText;
19
+ }
20
+ // Build context from previous exchanges (exclude the last message, that's currentText)
21
+ const prior = history
22
+ .slice(0, -1)
23
+ .slice(-8) // keep last 8 messages max to avoid huge prompts
24
+ .map((m) => {
25
+ const text = extractTextFromMessage(m);
26
+ const role = m.role === "agent" ? "[agent]" : "[user]";
27
+ return `[${role}]: ${text}`;
28
+ })
29
+ .filter((line) => line.length > 10)
30
+ .join("\n");
31
+ if (!prior)
32
+ return currentText;
33
+ return [
34
+ "[conversation history]",
35
+ prior,
36
+ "",
37
+ "[latest message]",
38
+ currentText,
39
+ ].join("\n");
40
+ }
13
41
  function sleep(ms) {
14
42
  return new Promise((resolve) => setTimeout(resolve, ms));
15
43
  }
@@ -42,13 +70,15 @@ class OpenClawAgentExecutor {
42
70
  gatewayConfig;
43
71
  taskTracker;
44
72
  logger;
73
+ // Map A2A task IDs → internal tracker IDs so cancelTask can find the right record
74
+ a2aToTracker = new Map();
45
75
  constructor(options) {
46
76
  this.gatewayConfig = options.gatewayConfig;
47
77
  this.taskTracker = options.taskTracker;
48
78
  this.logger = options.logger;
49
79
  }
50
80
  async execute(context, eventBus) {
51
- const taskText = extractTextFromMessage(context.userMessage);
81
+ const taskText = buildTaskWithHistory(context);
52
82
  const taskId = context.taskId;
53
83
  if (!taskText.trim()) {
54
84
  this.publishMessage(eventBus, "Error: empty task received.");
@@ -56,14 +86,16 @@ class OpenClawAgentExecutor {
56
86
  return;
57
87
  }
58
88
  const fromAgent = context.userMessage.metadata?.agentUrl ?? "unknown";
59
- this.taskTracker.create({
89
+ const tracked = this.taskTracker.create({
60
90
  fromPeerId: fromAgent,
61
91
  toPeerId: "local",
62
92
  task: taskText,
63
93
  });
94
+ const trackedId = tracked.taskId;
95
+ this.a2aToTracker.set(taskId, trackedId);
64
96
  if (!this.gatewayConfig) {
65
97
  this.logger.error("[a2a-adapter] gateway config not available, cannot execute task");
66
- this.taskTracker.update(taskId, { status: "failed", error: "gateway config not available" });
98
+ this.taskTracker.update(trackedId, { status: "failed", error: "gateway config not available" });
67
99
  this.publishMessage(eventBus, "Error: gateway config not available, cannot execute task.");
68
100
  eventBus.finished();
69
101
  return;
@@ -90,16 +122,19 @@ class OpenClawAgentExecutor {
90
122
  this.logger.info(`[a2a-adapter] task ${taskId} spawned as ${childSessionKey}, waiting for result...`);
91
123
  const output = await this.waitForCompletion(childSessionKey, 180_000);
92
124
  // 3. Return result
93
- this.taskTracker.update(taskId, { status: "completed", result: output });
125
+ this.taskTracker.update(trackedId, { status: "completed", result: output });
94
126
  this.logger.info(`[a2a-adapter] task ${taskId} completed`);
95
127
  this.publishMessage(eventBus, output || "Task completed with no output.");
96
128
  }
97
129
  catch (err) {
98
130
  const errorMsg = err instanceof Error ? err.message : String(err);
99
131
  this.logger.error(`[a2a-adapter] task execution failed: ${errorMsg}`);
100
- this.taskTracker.update(taskId, { status: "failed", error: errorMsg });
132
+ this.taskTracker.update(trackedId, { status: "failed", error: errorMsg });
101
133
  this.publishMessage(eventBus, `Error: ${errorMsg}`);
102
134
  }
135
+ finally {
136
+ this.a2aToTracker.delete(taskId);
137
+ }
103
138
  eventBus.finished();
104
139
  }
105
140
  /**
@@ -155,14 +190,21 @@ class OpenClawAgentExecutor {
155
190
  const messages = (details.messages ?? []);
156
191
  if (messages.length === 0)
157
192
  return null;
158
- // If no explicit flag, check the last message for signs of ongoing execution
193
+ // If no explicit flag, use conservative heuristic: only consider
194
+ // complete if the last message is an assistant message with text
195
+ // and NO tool calls (tool calls indicate ongoing work)
159
196
  if (details.isComplete === undefined) {
160
197
  const lastMsg = messages[messages.length - 1];
161
- if (lastMsg && Array.isArray(lastMsg.content)) {
198
+ if (!lastMsg || lastMsg.role !== "assistant")
199
+ return null;
200
+ if (Array.isArray(lastMsg.content)) {
162
201
  const content = lastMsg.content;
163
202
  const hasToolCalls = content.some((c) => c?.type === "toolCall" || c?.type === "tool_use");
203
+ // If there are ANY tool calls, assume still running
204
+ if (hasToolCalls)
205
+ return null;
164
206
  const hasText = content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
165
- if (hasToolCalls && !hasText)
207
+ if (!hasText)
166
208
  return null;
167
209
  }
168
210
  }
@@ -188,7 +230,9 @@ class OpenClawAgentExecutor {
188
230
  return null;
189
231
  }
190
232
  async cancelTask(taskId, eventBus) {
191
- this.taskTracker.update(taskId, { status: "failed", error: "canceled" });
233
+ const trackedId = this.a2aToTracker.get(taskId) ?? taskId;
234
+ this.taskTracker.update(trackedId, { status: "failed", error: "canceled" });
235
+ this.a2aToTracker.delete(taskId);
192
236
  this.publishMessage(eventBus, "Task was canceled.");
193
237
  eventBus.finished();
194
238
  }
@@ -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
  }
@@ -73,6 +73,47 @@ class AgentRegistry {
73
73
  const normalizedUrl = url.replace(/\/+$/, "");
74
74
  return store.agents.find((a) => a.url === normalizedUrl) ?? null;
75
75
  }
76
+ async addTeamSource(url, teamId) {
77
+ await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
78
+ const store = await this.readStore();
79
+ const normalizedUrl = url.replace(/\/+$/, "");
80
+ const agent = store.agents.find((a) => a.url === normalizedUrl);
81
+ if (agent) {
82
+ const teams = new Set(agent.teamIds ?? []);
83
+ teams.add(teamId);
84
+ agent.teamIds = [...teams];
85
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
86
+ }
87
+ });
88
+ }
89
+ /**
90
+ * Remove a team source from an agent. Returns true if the agent
91
+ * was fully removed (no remaining sources), false otherwise.
92
+ * Manually-added agents (no teamIds) are never removed by this method.
93
+ */
94
+ async removeTeamSource(url, teamId) {
95
+ return await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
96
+ const store = await this.readStore();
97
+ const normalizedUrl = url.replace(/\/+$/, "");
98
+ const agent = store.agents.find((a) => a.url === normalizedUrl);
99
+ if (!agent)
100
+ return false;
101
+ // Agent was manually added (no team tracking) — never auto-remove
102
+ if (!agent.teamIds || agent.teamIds.length === 0)
103
+ return false;
104
+ const teams = new Set(agent.teamIds);
105
+ teams.delete(teamId);
106
+ if (teams.size === 0) {
107
+ // No team sources remain — remove entirely
108
+ store.agents = store.agents.filter((a) => a.url !== normalizedUrl);
109
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
110
+ return true;
111
+ }
112
+ agent.teamIds = [...teams];
113
+ await (0, json_store_1.writeJsonAtomically)(this.filePath, store);
114
+ return false;
115
+ });
116
+ }
76
117
  async updateDescription(url, description) {
77
118
  await (0, json_store_1.withJsonLock)(this.filePath, emptyStore(), async () => {
78
119
  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;
@@ -153,6 +157,18 @@ class MulticlawsService extends node_events_1.EventEmitter {
153
157
  if (!this.started)
154
158
  return;
155
159
  this.started = false;
160
+ // Abort all in-flight sessions so they don't hang
161
+ for (const [, abort] of this.sessionAborts) {
162
+ abort.abort();
163
+ }
164
+ // Wait for session locks to drain (with a cap)
165
+ if (this.sessionLocks.size > 0) {
166
+ const pending = [...this.sessionLocks.values()];
167
+ await Promise.race([
168
+ Promise.allSettled(pending),
169
+ new Promise((r) => setTimeout(r, 5_000)),
170
+ ]);
171
+ }
156
172
  this.taskTracker.destroy();
157
173
  this.httpRateLimiter.destroy();
158
174
  await new Promise((resolve) => {
@@ -165,6 +181,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
165
181
  this.httpServer = null;
166
182
  }
167
183
  updateGatewayConfig(config) {
184
+ this.options.gatewayConfig = config;
168
185
  this.agentExecutor?.updateGatewayConfig(config);
169
186
  }
170
187
  /* ---------------------------------------------------------------- */
@@ -178,20 +195,23 @@ class MulticlawsService extends node_events_1.EventEmitter {
178
195
  try {
179
196
  const client = await this.clientFactory.createFromUrl(normalizedUrl);
180
197
  const card = await client.getAgentCard();
181
- return await this.agentRegistry.add({
198
+ const record = await this.agentRegistry.add({
182
199
  url: normalizedUrl,
183
200
  name: card.name ?? normalizedUrl,
184
201
  description: card.description ?? "",
185
202
  skills: card.skills?.map((s) => s.name ?? s.id) ?? [],
186
203
  apiKey: params.apiKey,
187
204
  });
205
+ return { ...record, reachable: true };
188
206
  }
189
207
  catch {
190
- return await this.agentRegistry.add({
208
+ this.log("warn", `agent at ${normalizedUrl} is not reachable, adding with limited info`);
209
+ const record = await this.agentRegistry.add({
191
210
  url: normalizedUrl,
192
211
  name: normalizedUrl,
193
212
  apiKey: params.apiKey,
194
213
  });
214
+ return { ...record, reachable: false };
195
215
  }
196
216
  }
197
217
  async removeAgent(url) {
@@ -205,27 +225,29 @@ class MulticlawsService extends node_events_1.EventEmitter {
205
225
  /* ---------------------------------------------------------------- */
206
226
  async startSession(params) {
207
227
  const agentRecord = await this.agentRegistry.get(params.agentUrl);
228
+ // Fix #3: throw instead of returning empty sessionId
208
229
  if (!agentRecord) {
209
- return { sessionId: "", status: "failed", error: `unknown agent: ${params.agentUrl}` };
230
+ throw new Error(`unknown agent: ${params.agentUrl}`);
210
231
  }
211
- const contextId = (0, node_crypto_1.randomUUID)();
232
+ // Fix #4: don't pre-generate contextId; let server assign it.
233
+ // Use a local placeholder that gets replaced after first response.
212
234
  const session = this.sessionStore.create({
213
235
  agentUrl: params.agentUrl,
214
236
  agentName: agentRecord.name,
215
- contextId,
237
+ contextId: "", // will be filled in from server response
216
238
  });
217
239
  this.sessionStore.appendMessage(session.sessionId, {
218
240
  role: "user",
219
241
  content: params.message,
220
242
  timestampMs: Date.now(),
221
243
  });
222
- void this.runSession({
244
+ void this.acquireSessionLock(session.sessionId, () => this.runSession({
223
245
  sessionId: session.sessionId,
224
246
  agentRecord,
225
247
  message: params.message,
226
- contextId,
248
+ contextId: undefined, // first message: no contextId
227
249
  taskId: undefined,
228
- });
250
+ }));
229
251
  return { sessionId: session.sessionId, status: "running" };
230
252
  }
231
253
  async sendSessionMessage(params) {
@@ -247,13 +269,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
247
269
  this.sessionStore.update(params.sessionId, { status: "failed", error: "agent no longer registered" });
248
270
  return { sessionId: params.sessionId, status: "failed", error: "agent no longer registered" };
249
271
  }
250
- void this.runSession({
272
+ // Fix #5: acquire lock to prevent concurrent runSession on same session
273
+ void this.acquireSessionLock(params.sessionId, () => this.runSession({
251
274
  sessionId: params.sessionId,
252
275
  agentRecord,
253
276
  message: params.message,
254
- contextId: session.contextId,
277
+ contextId: session.contextId || undefined,
255
278
  taskId: session.currentTaskId,
256
- });
279
+ }));
257
280
  return { sessionId: params.sessionId, status: "ok" };
258
281
  }
259
282
  getSession(sessionId) {
@@ -297,49 +320,115 @@ class MulticlawsService extends node_events_1.EventEmitter {
297
320
  if (!session)
298
321
  return false;
299
322
  this.sessionStore.update(sessionId, { status: "canceled" });
323
+ // Signal the in-flight runSession to abort
324
+ const abort = this.sessionAborts.get(sessionId);
325
+ if (abort)
326
+ abort.abort();
300
327
  return true;
301
328
  }
329
+ // Fix #5: serialise concurrent calls on the same session
330
+ async acquireSessionLock(sessionId, fn) {
331
+ const prev = this.sessionLocks.get(sessionId) ?? Promise.resolve();
332
+ let release;
333
+ const next = new Promise((r) => { release = r; });
334
+ this.sessionLocks.set(sessionId, next);
335
+ try {
336
+ await prev;
337
+ await fn();
338
+ }
339
+ finally {
340
+ release();
341
+ if (this.sessionLocks.get(sessionId) === next) {
342
+ this.sessionLocks.delete(sessionId);
343
+ }
344
+ }
345
+ }
302
346
  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);
347
+ const timeout = params.timeoutMs ?? 5 * 60 * 1000;
348
+ const deadline = Date.now() + timeout;
349
+ const abortController = new AbortController();
350
+ this.sessionAborts.set(params.sessionId, abortController);
351
+ const timer = setTimeout(() => abortController.abort(), timeout);
306
352
  try {
307
353
  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
- },
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")));
318
364
  }),
319
- new Promise((_, reject) => timeoutController.signal.addEventListener("abort", () => reject(new Error("session timeout")))),
320
365
  ]);
366
+ let result = await withAbort(client.sendMessage({
367
+ message: {
368
+ kind: "message",
369
+ role: "user",
370
+ parts: [{ kind: "text", text: params.message }],
371
+ messageId: (0, node_crypto_1.randomUUID)(),
372
+ // Fix #4: only pass contextId if we have a server-assigned one
373
+ ...(params.contextId ? { contextId: params.contextId } : {}),
374
+ ...(params.taskId ? { taskId: params.taskId } : {}),
375
+ },
376
+ }));
377
+ // Fix #1: poll until terminal state if server returns working/submitted
378
+ const POLL_DELAYS = [1000, 2000, 3000, 5000];
379
+ let pollAttempt = 0;
380
+ while (true) {
381
+ const state = this.extractResultState(result);
382
+ const remoteTaskId = "id" in result ? result.id : undefined;
383
+ if (state !== "working" && state !== "submitted")
384
+ break;
385
+ if (!remoteTaskId)
386
+ break; // can't poll without task id
387
+ if (Date.now() >= deadline)
388
+ throw new Error("session timeout");
389
+ const delay = POLL_DELAYS[Math.min(pollAttempt, POLL_DELAYS.length - 1)];
390
+ await new Promise((r) => setTimeout(r, delay));
391
+ pollAttempt++;
392
+ result = await withAbort(client.getTask({ id: remoteTaskId, historyLength: 10 }));
393
+ }
394
+ // Check if session was canceled while we were running
395
+ const current = this.sessionStore.get(params.sessionId);
396
+ if (current?.status === "canceled")
397
+ return;
321
398
  await this.handleSessionResult(params.sessionId, result);
322
399
  }
323
400
  catch (err) {
401
+ // Don't overwrite a user-initiated cancel
402
+ const current = this.sessionStore.get(params.sessionId);
403
+ if (current?.status === "canceled")
404
+ return;
324
405
  const errorMsg = err instanceof Error ? err.message : String(err);
325
406
  this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
326
407
  await this.notifySessionUpdate(params.sessionId, "failed");
327
408
  }
328
409
  finally {
329
410
  clearTimeout(timer);
411
+ this.sessionAborts.delete(params.sessionId);
412
+ }
413
+ }
414
+ extractResultState(result) {
415
+ if ("status" in result && result.status) {
416
+ return result.status?.state ?? "unknown";
330
417
  }
418
+ return "completed"; // plain Message = completed
331
419
  }
332
420
  async handleSessionResult(sessionId, result) {
333
- // Extract content
334
421
  let content = "";
335
422
  let state = "completed";
336
423
  let remoteTaskId;
424
+ let serverContextId;
337
425
  if ("status" in result && result.status) {
338
426
  const task = result;
339
427
  state = task.status?.state ?? "completed";
340
428
  remoteTaskId = task.id;
429
+ // Fix #4: capture server-assigned contextId
430
+ serverContextId = task.contextId;
341
431
  content = this.extractArtifactText(task);
342
- // Also try to get text from task messages if artifacts empty
343
432
  if (!content && task.history?.length) {
344
433
  const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
345
434
  if (lastAgentMsg) {
@@ -353,23 +442,24 @@ class MulticlawsService extends node_events_1.EventEmitter {
353
442
  else {
354
443
  const msg = result;
355
444
  remoteTaskId = msg.taskId;
445
+ serverContextId = msg.contextId;
356
446
  content = msg.parts
357
447
  ?.filter((p) => p.kind === "text")
358
448
  .map((p) => p.text)
359
449
  .join("\n") ?? "";
360
450
  }
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
451
+ // Fix #6: always record agent message, use placeholder when content is empty
452
+ this.sessionStore.appendMessage(sessionId, {
453
+ role: "agent",
454
+ content: content || "(no text output)",
455
+ timestampMs: Date.now(),
456
+ taskId: remoteTaskId,
457
+ });
458
+ // Fix #4: update contextId with server-assigned value
459
+ const contextUpdate = serverContextId ? { contextId: serverContextId } : {};
371
460
  if (state === "input-required" || state === "auth-required") {
372
461
  this.sessionStore.update(sessionId, {
462
+ ...contextUpdate,
373
463
  status: "input-required",
374
464
  currentTaskId: remoteTaskId,
375
465
  });
@@ -377,36 +467,54 @@ class MulticlawsService extends node_events_1.EventEmitter {
377
467
  }
378
468
  else if (state === "failed" || state === "rejected") {
379
469
  this.sessionStore.update(sessionId, {
470
+ ...contextUpdate,
380
471
  status: "failed",
381
472
  currentTaskId: remoteTaskId,
382
473
  error: content || "remote task failed",
383
474
  });
384
475
  await this.notifySessionUpdate(sessionId, "failed");
385
476
  }
386
- else if (state === "completed" || state === "canceled") {
477
+ else if (state === "completed") {
387
478
  this.sessionStore.update(sessionId, {
479
+ ...contextUpdate,
388
480
  status: "completed",
389
481
  currentTaskId: remoteTaskId,
390
482
  });
391
483
  await this.notifySessionUpdate(sessionId, "completed");
392
484
  }
485
+ else if (state === "canceled") {
486
+ // Fix #2: canceled remote task → local status "canceled", not "completed"
487
+ this.sessionStore.update(sessionId, {
488
+ ...contextUpdate,
489
+ status: "canceled",
490
+ currentTaskId: remoteTaskId,
491
+ error: "remote task was canceled",
492
+ });
493
+ await this.notifySessionUpdate(sessionId, "failed");
494
+ }
393
495
  else {
394
- // working / submitted / unknown still in progress, no notification
395
- this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
496
+ // working / submitted / unknown: runSession's polling loop handles these
497
+ this.sessionStore.update(sessionId, { ...contextUpdate, currentTaskId: remoteTaskId });
396
498
  }
397
499
  }
398
500
  async notifySessionUpdate(sessionId, event) {
399
- if (!this.options.gatewayConfig)
501
+ if (!this.options.gatewayConfig) {
502
+ this.log("warn", `session ${sessionId} ${event} but gateway config unavailable — user won't be notified. Check gateway.auth.token in config.`);
400
503
  return;
504
+ }
401
505
  const session = this.sessionStore.get(sessionId);
402
506
  if (!session)
403
507
  return;
404
508
  const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
405
- const content = lastAgentMsg?.content ?? "";
509
+ const rawContent = lastAgentMsg?.content ?? "";
510
+ // Don't show the placeholder text in user-facing notifications
511
+ const content = rawContent === "(no text output)" ? "" : rawContent;
406
512
  const agentName = session.agentName;
407
513
  let message;
408
514
  if (event === "completed") {
409
- message = [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n");
515
+ message = content
516
+ ? [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n")
517
+ : `✅ **${agentName} 任务完成** (session: \`${sessionId}\`) — 任务已执行但无文本输出,可能产生了 artifacts。`;
410
518
  }
411
519
  else if (event === "input-required") {
412
520
  message = [
@@ -586,7 +694,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
586
694
  }
587
695
  }));
588
696
  for (const m of others) {
589
- await this.agentRegistry.remove(m.url);
697
+ await this.agentRegistry.removeTeamSource(m.url, team.teamId);
590
698
  }
591
699
  await this.teamStore.deleteTeam(team.teamId);
592
700
  this.log("info", `left team ${team.teamId}`);
@@ -662,10 +770,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
662
770
  name: member.name,
663
771
  description: member.description,
664
772
  });
773
+ await this.agentRegistry.addTeamSource(normalizedUrl, team.teamId);
665
774
  // Broadcast to other members if new
666
775
  if (!alreadyKnown) {
667
776
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
668
- const others = team.members.filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
777
+ // Re-read team after addMember to get the latest member list,
778
+ // avoiding missed broadcasts when multiple members join concurrently
779
+ const freshTeam = await this.teamStore.getTeam(team.teamId);
780
+ const others = (freshTeam?.members ?? team.members).filter((m) => m.url.replace(/\/+$/, "") !== normalizedUrl &&
669
781
  m.url.replace(/\/+$/, "") !== selfNormalized);
670
782
  for (const other of others) {
671
783
  void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
@@ -785,14 +897,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
785
897
  const client = await this.clientFactory.createFromUrl(m.url);
786
898
  const card = await client.getAgentCard();
787
899
  if (card.description) {
788
- m.description = card.description;
900
+ // Use addMember (which uses withJsonLock) instead of saveTeam
901
+ // to avoid overwriting concurrent member additions
902
+ await this.teamStore.addMember(team.teamId, {
903
+ url: m.url,
904
+ name: m.name,
905
+ description: card.description,
906
+ joinedAtMs: m.joinedAtMs,
907
+ });
789
908
  }
790
909
  }
791
910
  catch {
792
911
  this.log("warn", `failed to fetch Agent Card from ${m.url}`);
793
912
  }
794
913
  }));
795
- await this.teamStore.saveTeam(team);
796
914
  }
797
915
  async syncTeamToRegistry(team) {
798
916
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
@@ -804,6 +922,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
804
922
  name: member.name,
805
923
  description: member.description,
806
924
  });
925
+ await this.agentRegistry.addTeamSource(member.url, team.teamId);
807
926
  }
808
927
  }
809
928
  async createA2AClient(agent) {
@@ -10,6 +10,7 @@ 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
  }
@@ -66,9 +67,12 @@ class SessionStore {
66
67
  const session = this.get(sessionId);
67
68
  if (!session)
68
69
  return null;
69
- return this.update(sessionId, {
70
- messages: [...session.messages, msg],
71
- });
70
+ let messages = [...session.messages, msg];
71
+ // Truncate old messages, keeping the most recent ones
72
+ if (messages.length > MAX_MESSAGES_PER_SESSION) {
73
+ messages = messages.slice(-MAX_MESSAGES_PER_SESSION);
74
+ }
75
+ return this.update(sessionId, { messages });
72
76
  }
73
77
  loadSync() {
74
78
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "MultiClaws plugin for OpenClaw collaboration via A2A protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",