multiclaws 0.4.0 → 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.
@@ -18,6 +18,10 @@ const sessionReplySchema = zod_1.z.object({
18
18
  });
19
19
  const sessionStatusSchema = zod_1.z.object({ sessionId: zod_1.z.string().trim().min(1).optional() });
20
20
  const sessionEndSchema = zod_1.z.object({ sessionId: nonEmptyString });
21
+ const sessionWaitAllSchema = zod_1.z.object({
22
+ sessionIds: zod_1.z.array(nonEmptyString).min(1),
23
+ timeoutMs: zod_1.z.number().positive().optional(),
24
+ });
21
25
  const profileSetSchema = zod_1.z.object({
22
26
  ownerName: zod_1.z.string().trim().optional(),
23
27
  bio: zod_1.z.string().optional(),
@@ -106,6 +110,17 @@ function createGatewayHandlers(getService) {
106
110
  safeHandle(respond, "session_status_failed", error);
107
111
  }
108
112
  },
113
+ "multiclaws.session.wait_all": async ({ params, respond }) => {
114
+ try {
115
+ const parsed = sessionWaitAllSchema.parse(params);
116
+ const service = getService();
117
+ const result = await service.waitForSessions(parsed);
118
+ respond(true, result);
119
+ }
120
+ catch (error) {
121
+ safeHandle(respond, "session_wait_all_failed", error);
122
+ }
123
+ },
109
124
  "multiclaws.session.end": async ({ params, respond }) => {
110
125
  try {
111
126
  const parsed = sessionEndSchema.parse(params);
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 = {
@@ -155,6 +158,30 @@ function createTools(getService) {
155
158
  return textResult(JSON.stringify({ sessions }, null, 2), { sessions });
156
159
  },
157
160
  };
161
+ const multiclawsSessionWaitAll = {
162
+ name: "multiclaws_session_wait_all",
163
+ description: "Wait for multiple sessions to complete, then return all results at once. Use this when you have started multiple sessions concurrently and need all results before synthesizing an answer. Returns early if any session needs input (input-required). Default timeout: 5 minutes.",
164
+ parameters: {
165
+ type: "object",
166
+ additionalProperties: false,
167
+ properties: {
168
+ sessionIds: { type: "array", items: { type: "string" } },
169
+ timeoutMs: { type: "number" },
170
+ },
171
+ required: ["sessionIds"],
172
+ },
173
+ execute: async (_toolCallId, args) => {
174
+ const service = requireService(getService());
175
+ const sessionIds = Array.isArray(args.sessionIds)
176
+ ? args.sessionIds.map((s) => String(s).trim()).filter(Boolean)
177
+ : [];
178
+ if (!sessionIds.length)
179
+ throw new Error("sessionIds must be a non-empty array");
180
+ const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : undefined;
181
+ const result = await service.waitForSessions({ sessionIds, timeoutMs });
182
+ return textResult(JSON.stringify(result, null, 2), result);
183
+ },
184
+ };
158
185
  const multiclawsSessionEnd = {
159
186
  name: "multiclaws_session_end",
160
187
  description: "Cancel and close a collaboration session.",
@@ -327,6 +354,7 @@ function createTools(getService) {
327
354
  multiclawsSessionStart,
328
355
  multiclawsSessionReply,
329
356
  multiclawsSessionStatus,
357
+ multiclawsSessionWaitAll,
330
358
  multiclawsSessionEnd,
331
359
  multiclawsTeamCreate,
332
360
  multiclawsTeamJoin,
@@ -412,6 +440,17 @@ const plugin = {
412
440
  });
413
441
  api.on("gateway_start", () => {
414
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
+ }
415
454
  });
416
455
  api.on("gateway_stop", () => {
417
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;
@@ -63,8 +67,23 @@ export declare class MulticlawsService extends EventEmitter {
63
67
  }): Promise<SessionReplyResult>;
64
68
  getSession(sessionId: string): ConversationSession | null;
65
69
  listSessions(): ConversationSession[];
70
+ waitForSessions(params: {
71
+ sessionIds: string[];
72
+ timeoutMs?: number;
73
+ }): Promise<{
74
+ results: Array<{
75
+ sessionId: string;
76
+ status: string;
77
+ agentName: string;
78
+ lastMessage?: string;
79
+ error?: string;
80
+ }>;
81
+ timedOut: boolean;
82
+ }>;
66
83
  endSession(sessionId: string): boolean;
84
+ private acquireSessionLock;
67
85
  private runSession;
86
+ private extractResultState;
68
87
  private handleSessionResult;
69
88
  private notifySessionUpdate;
70
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) {
@@ -262,54 +285,150 @@ class MulticlawsService extends node_events_1.EventEmitter {
262
285
  listSessions() {
263
286
  return this.sessionStore.list();
264
287
  }
288
+ async waitForSessions(params) {
289
+ const timeout = params.timeoutMs ?? 5 * 60 * 1000;
290
+ const deadline = Date.now() + timeout;
291
+ const terminalStates = new Set(["completed", "failed", "canceled"]);
292
+ const getResults = () => params.sessionIds.map((id) => {
293
+ const session = this.sessionStore.get(id);
294
+ if (!session)
295
+ return { sessionId: id, agentName: "unknown", status: "not_found" };
296
+ const lastAgent = [...session.messages].reverse().find((m) => m.role === "agent");
297
+ return {
298
+ sessionId: id,
299
+ agentName: session.agentName,
300
+ status: session.status,
301
+ lastMessage: lastAgent?.content,
302
+ error: session.error,
303
+ };
304
+ });
305
+ while (Date.now() < deadline) {
306
+ const results = getResults();
307
+ const allSettled = results.every((r) => terminalStates.has(r.status) || r.status === "not_found");
308
+ if (allSettled)
309
+ return { results, timedOut: false };
310
+ // Return early if any session needs input — AI must handle it before continuing
311
+ const needsInput = results.some((r) => r.status === "input-required");
312
+ if (needsInput)
313
+ return { results, timedOut: false };
314
+ await new Promise((r) => setTimeout(r, 1_000));
315
+ }
316
+ return { results: getResults(), timedOut: true };
317
+ }
265
318
  endSession(sessionId) {
266
319
  const session = this.sessionStore.get(sessionId);
267
320
  if (!session)
268
321
  return false;
269
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();
270
327
  return true;
271
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
+ }
272
346
  async runSession(params) {
273
- const timeout = params.timeoutMs ?? 5 * 60 * 1000; // 5 min default
274
- const timeoutController = new AbortController();
275
- 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);
276
352
  try {
277
353
  const client = await this.createA2AClient(params.agentRecord);
278
- const result = await Promise.race([
279
- client.sendMessage({
280
- message: {
281
- kind: "message",
282
- role: "user",
283
- parts: [{ kind: "text", text: params.message }],
284
- messageId: (0, node_crypto_1.randomUUID)(),
285
- contextId: params.contextId,
286
- ...(params.taskId ? { taskId: params.taskId } : {}),
287
- },
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")));
288
364
  }),
289
- new Promise((_, reject) => timeoutController.signal.addEventListener("abort", () => reject(new Error("session timeout")))),
290
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;
291
398
  await this.handleSessionResult(params.sessionId, result);
292
399
  }
293
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;
294
405
  const errorMsg = err instanceof Error ? err.message : String(err);
295
406
  this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
296
407
  await this.notifySessionUpdate(params.sessionId, "failed");
297
408
  }
298
409
  finally {
299
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";
300
417
  }
418
+ return "completed"; // plain Message = completed
301
419
  }
302
420
  async handleSessionResult(sessionId, result) {
303
- // Extract content
304
421
  let content = "";
305
422
  let state = "completed";
306
423
  let remoteTaskId;
424
+ let serverContextId;
307
425
  if ("status" in result && result.status) {
308
426
  const task = result;
309
427
  state = task.status?.state ?? "completed";
310
428
  remoteTaskId = task.id;
429
+ // Fix #4: capture server-assigned contextId
430
+ serverContextId = task.contextId;
311
431
  content = this.extractArtifactText(task);
312
- // Also try to get text from task messages if artifacts empty
313
432
  if (!content && task.history?.length) {
314
433
  const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
315
434
  if (lastAgentMsg) {
@@ -323,23 +442,24 @@ class MulticlawsService extends node_events_1.EventEmitter {
323
442
  else {
324
443
  const msg = result;
325
444
  remoteTaskId = msg.taskId;
445
+ serverContextId = msg.contextId;
326
446
  content = msg.parts
327
447
  ?.filter((p) => p.kind === "text")
328
448
  .map((p) => p.text)
329
449
  .join("\n") ?? "";
330
450
  }
331
- // Append agent message to history
332
- if (content) {
333
- this.sessionStore.appendMessage(sessionId, {
334
- role: "agent",
335
- content,
336
- timestampMs: Date.now(),
337
- taskId: remoteTaskId,
338
- });
339
- }
340
- // 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 } : {};
341
460
  if (state === "input-required" || state === "auth-required") {
342
461
  this.sessionStore.update(sessionId, {
462
+ ...contextUpdate,
343
463
  status: "input-required",
344
464
  currentTaskId: remoteTaskId,
345
465
  });
@@ -347,36 +467,54 @@ class MulticlawsService extends node_events_1.EventEmitter {
347
467
  }
348
468
  else if (state === "failed" || state === "rejected") {
349
469
  this.sessionStore.update(sessionId, {
470
+ ...contextUpdate,
350
471
  status: "failed",
351
472
  currentTaskId: remoteTaskId,
352
473
  error: content || "remote task failed",
353
474
  });
354
475
  await this.notifySessionUpdate(sessionId, "failed");
355
476
  }
356
- else if (state === "completed" || state === "canceled") {
477
+ else if (state === "completed") {
357
478
  this.sessionStore.update(sessionId, {
479
+ ...contextUpdate,
358
480
  status: "completed",
359
481
  currentTaskId: remoteTaskId,
360
482
  });
361
483
  await this.notifySessionUpdate(sessionId, "completed");
362
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
+ }
363
495
  else {
364
- // working / submitted / unknown still in progress, no notification
365
- this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
496
+ // working / submitted / unknown: runSession's polling loop handles these
497
+ this.sessionStore.update(sessionId, { ...contextUpdate, currentTaskId: remoteTaskId });
366
498
  }
367
499
  }
368
500
  async notifySessionUpdate(sessionId, event) {
369
- 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.`);
370
503
  return;
504
+ }
371
505
  const session = this.sessionStore.get(sessionId);
372
506
  if (!session)
373
507
  return;
374
508
  const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
375
- 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;
376
512
  const agentName = session.agentName;
377
513
  let message;
378
514
  if (event === "completed") {
379
- message = [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n");
515
+ message = content
516
+ ? [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n")
517
+ : `✅ **${agentName} 任务完成** (session: \`${sessionId}\`) — 任务已执行但无文本输出,可能产生了 artifacts。`;
380
518
  }
381
519
  else if (event === "input-required") {
382
520
  message = [
@@ -556,7 +694,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
556
694
  }
557
695
  }));
558
696
  for (const m of others) {
559
- await this.agentRegistry.remove(m.url);
697
+ await this.agentRegistry.removeTeamSource(m.url, team.teamId);
560
698
  }
561
699
  await this.teamStore.deleteTeam(team.teamId);
562
700
  this.log("info", `left team ${team.teamId}`);
@@ -632,10 +770,14 @@ class MulticlawsService extends node_events_1.EventEmitter {
632
770
  name: member.name,
633
771
  description: member.description,
634
772
  });
773
+ await this.agentRegistry.addTeamSource(normalizedUrl, team.teamId);
635
774
  // Broadcast to other members if new
636
775
  if (!alreadyKnown) {
637
776
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
638
- 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 &&
639
781
  m.url.replace(/\/+$/, "") !== selfNormalized);
640
782
  for (const other of others) {
641
783
  void this.fetchWithRetry(`${other.url}/team/${team.teamId}/announce`, {
@@ -755,14 +897,20 @@ class MulticlawsService extends node_events_1.EventEmitter {
755
897
  const client = await this.clientFactory.createFromUrl(m.url);
756
898
  const card = await client.getAgentCard();
757
899
  if (card.description) {
758
- 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
+ });
759
908
  }
760
909
  }
761
910
  catch {
762
911
  this.log("warn", `failed to fetch Agent Card from ${m.url}`);
763
912
  }
764
913
  }));
765
- await this.teamStore.saveTeam(team);
766
914
  }
767
915
  async syncTeamToRegistry(team) {
768
916
  const selfNormalized = this.selfUrl.replace(/\/+$/, "");
@@ -774,6 +922,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
774
922
  name: member.name,
775
923
  description: member.description,
776
924
  });
925
+ await this.agentRegistry.addTeamSource(member.url, team.teamId);
777
926
  }
778
927
  }
779
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.0",
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",
@@ -49,13 +49,19 @@ multiclaws_session_status(sessionId="...") → 查看单个会话及消息历史
49
49
  multiclaws_session_end(sessionId="...") → 取消并关闭会话
50
50
  ```
51
51
 
52
- ### 并发协作
53
- 可同时开启多个 session,各自独立运行:
52
+ ### 并发协作(自动汇总)
53
+ 同时开启多个 session,等所有结果后汇总:
54
54
  ```
55
- multiclaws_session_start(agentUrl=B, message="任务1") → sessionId_1
56
- multiclaws_session_start(agentUrl=C, message="任务2") → sessionId_2
55
+ id1 = multiclaws_session_start(agentUrl=B, message="子任务1")
56
+ id2 = multiclaws_session_start(agentUrl=C, message="子任务2")
57
+ results = multiclaws_session_wait_all(sessionIds=[id1, id2])
58
+ → 阻塞直到全部完成,返回所有结果
59
+ → AI 汇总后回复用户
57
60
  ```
58
61
 
62
+ **注意**:若任何 session 变为 `input-required`,`wait_all` 会提前返回,
63
+ AI 应先用 `session_reply` 处理,再继续等待剩余 session。
64
+
59
65
  ### 链式协作(A→B→C)
60
66
  B 内部可以自己调用 `multiclaws_session_start` 委派给 C,结果自然冒泡回 A。
61
67
 
@@ -99,6 +105,7 @@ multiclaws_profile_show()
99
105
  | `multiclaws_session_start` | 开始协作会话(替代旧 delegate) | `agentUrl`, `message` |
100
106
  | `multiclaws_session_reply` | 在会话中发送后续消息 | `sessionId`, `message` |
101
107
  | `multiclaws_session_status` | 查看会话状态和消息历史 | `sessionId`(可选,不传返回全部) |
108
+ | `multiclaws_session_wait_all` | 等待多个会话全部完成,返回所有结果 | `sessionIds[]`, `timeoutMs`(可选) |
102
109
  | `multiclaws_session_end` | 取消/关闭会话 | `sessionId` |
103
110
 
104
111
  ### 档案