multiclaws 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,11 +8,16 @@ const agentAddSchema = zod_1.z.object({
8
8
  apiKey: zod_1.z.string().trim().min(1).optional(),
9
9
  });
10
10
  const agentRemoveSchema = zod_1.z.object({ url: nonEmptyString });
11
- const taskDelegateSchema = zod_1.z.object({
11
+ const sessionStartSchema = zod_1.z.object({
12
12
  agentUrl: nonEmptyString,
13
- task: nonEmptyString,
13
+ message: nonEmptyString,
14
14
  });
15
- const taskStatusSchema = zod_1.z.object({ taskId: nonEmptyString });
15
+ const sessionReplySchema = zod_1.z.object({
16
+ sessionId: nonEmptyString,
17
+ message: nonEmptyString,
18
+ });
19
+ const sessionStatusSchema = zod_1.z.object({ sessionId: zod_1.z.string().trim().min(1).optional() });
20
+ const sessionEndSchema = zod_1.z.object({ sessionId: nonEmptyString });
16
21
  const profileSetSchema = zod_1.z.object({
17
22
  ownerName: zod_1.z.string().trim().optional(),
18
23
  bio: zod_1.z.string().optional(),
@@ -57,34 +62,59 @@ function createGatewayHandlers(getService) {
57
62
  safeHandle(respond, "invalid_params", error);
58
63
  }
59
64
  },
60
- /* ── Task handlers ──────────────────────────────────────────── */
61
- "multiclaws.task.delegate": async ({ params, respond }) => {
65
+ /* ── Session handlers ───────────────────────────────────────── */
66
+ "multiclaws.session.start": async ({ params, respond }) => {
62
67
  try {
63
- const parsed = taskDelegateSchema.parse(params);
68
+ const parsed = sessionStartSchema.parse(params);
64
69
  const service = getService();
65
- const result = await service.delegateTask(parsed);
70
+ const result = await service.startSession(parsed);
66
71
  respond(true, result);
67
72
  }
68
73
  catch (error) {
69
- safeHandle(respond, "task_delegate_failed", error);
74
+ safeHandle(respond, "session_start_failed", error);
70
75
  }
71
76
  },
72
- "multiclaws.task.status": async ({ params, respond }) => {
77
+ "multiclaws.session.reply": async ({ params, respond }) => {
73
78
  try {
74
- const parsed = taskStatusSchema.parse(params);
79
+ const parsed = sessionReplySchema.parse(params);
75
80
  const service = getService();
76
- const task = service.getTaskStatus(parsed.taskId);
77
- if (!task) {
78
- respond(false, undefined, {
79
- code: "not_found",
80
- message: `task not found: ${parsed.taskId}`,
81
- });
82
- return;
81
+ const result = await service.sendSessionMessage(parsed);
82
+ respond(true, result);
83
+ }
84
+ catch (error) {
85
+ safeHandle(respond, "session_reply_failed", error);
86
+ }
87
+ },
88
+ "multiclaws.session.status": async ({ params, respond }) => {
89
+ try {
90
+ const parsed = sessionStatusSchema.parse(params);
91
+ const service = getService();
92
+ if (parsed.sessionId) {
93
+ const session = service.getSession(parsed.sessionId);
94
+ if (!session) {
95
+ respond(false, undefined, { code: "not_found", message: `session not found: ${parsed.sessionId}` });
96
+ return;
97
+ }
98
+ respond(true, session);
99
+ }
100
+ else {
101
+ const sessions = service.listSessions();
102
+ respond(true, { sessions });
83
103
  }
84
- respond(true, { task });
85
104
  }
86
105
  catch (error) {
87
- safeHandle(respond, "task_status_failed", error);
106
+ safeHandle(respond, "session_status_failed", error);
107
+ }
108
+ },
109
+ "multiclaws.session.end": async ({ params, respond }) => {
110
+ try {
111
+ const parsed = sessionEndSchema.parse(params);
112
+ const service = getService();
113
+ const ok = service.endSession(parsed.sessionId);
114
+ respond(true, { ended: ok });
115
+ }
116
+ catch (error) {
117
+ safeHandle(respond, "session_end_failed", error);
88
118
  }
89
119
  },
90
120
  /* ── Team handlers ──────────────────────────────────────────── */
package/dist/index.js CHANGED
@@ -87,48 +87,92 @@ function createTools(getService) {
87
87
  return textResult(removed ? `Agent ${url} removed.` : `Agent ${url} not found.`);
88
88
  },
89
89
  };
90
- const multiclawsDelegate = {
91
- name: "multiclaws_delegate",
92
- description: "Delegate a task to a remote A2A agent.",
90
+ /* ── Session tools (multi-turn collaboration) ─────────────────── */
91
+ const multiclawsSessionStart = {
92
+ name: "multiclaws_session_start",
93
+ description: "Start a multi-turn collaboration session with a remote agent. Sends the first message and returns immediately with a sessionId (async). The agent's response will be pushed as a message when ready. Covers both single-turn and multi-turn use cases.",
93
94
  parameters: {
94
95
  type: "object",
95
96
  additionalProperties: false,
96
97
  properties: {
97
98
  agentUrl: { type: "string" },
98
- task: { type: "string" },
99
+ message: { type: "string" },
99
100
  },
100
- required: ["agentUrl", "task"],
101
+ required: ["agentUrl", "message"],
101
102
  },
102
103
  execute: async (_toolCallId, args) => {
103
104
  const service = requireService(getService());
104
105
  const agentUrl = typeof args.agentUrl === "string" ? args.agentUrl.trim() : "";
105
- const task = typeof args.task === "string" ? args.task.trim() : "";
106
- if (!agentUrl || !task)
107
- throw new Error("agentUrl and task are required");
108
- const result = await service.delegateTask({ agentUrl, task });
106
+ const message = typeof args.message === "string" ? args.message.trim() : "";
107
+ if (!agentUrl || !message)
108
+ throw new Error("agentUrl and message are required");
109
+ const result = await service.startSession({ agentUrl, message });
109
110
  return textResult(JSON.stringify(result, null, 2), result);
110
111
  },
111
112
  };
112
- const multiclawsTaskStatus = {
113
- name: "multiclaws_task_status",
114
- description: "Check the status of a delegated task.",
113
+ const multiclawsSessionReply = {
114
+ name: "multiclaws_session_reply",
115
+ description: "Send a follow-up message in an existing collaboration session. Use when the remote agent returns 'input-required' or to continue a multi-turn conversation.",
115
116
  parameters: {
116
117
  type: "object",
117
118
  additionalProperties: false,
118
119
  properties: {
119
- taskId: { type: "string" },
120
+ sessionId: { type: "string" },
121
+ message: { type: "string" },
120
122
  },
121
- required: ["taskId"],
123
+ required: ["sessionId", "message"],
122
124
  },
123
125
  execute: async (_toolCallId, args) => {
124
126
  const service = requireService(getService());
125
- const taskId = typeof args.taskId === "string" ? args.taskId.trim() : "";
126
- if (!taskId)
127
- throw new Error("taskId is required");
128
- const task = service.getTaskStatus(taskId);
129
- if (!task)
130
- throw new Error(`task not found: ${taskId}`);
131
- return textResult(JSON.stringify(task, null, 2), task);
127
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : "";
128
+ const message = typeof args.message === "string" ? args.message.trim() : "";
129
+ if (!sessionId || !message)
130
+ throw new Error("sessionId and message are required");
131
+ const result = await service.sendSessionMessage({ sessionId, message });
132
+ return textResult(JSON.stringify(result, null, 2), result);
133
+ },
134
+ };
135
+ const multiclawsSessionStatus = {
136
+ name: "multiclaws_session_status",
137
+ description: "Get the status and message history of a collaboration session. If sessionId is omitted, lists all sessions.",
138
+ parameters: {
139
+ type: "object",
140
+ additionalProperties: false,
141
+ properties: {
142
+ sessionId: { type: "string" },
143
+ },
144
+ },
145
+ execute: async (_toolCallId, args) => {
146
+ const service = requireService(getService());
147
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : "";
148
+ if (sessionId) {
149
+ const session = service.getSession(sessionId);
150
+ if (!session)
151
+ throw new Error(`session not found: ${sessionId}`);
152
+ return textResult(JSON.stringify(session, null, 2), session);
153
+ }
154
+ const sessions = service.listSessions();
155
+ return textResult(JSON.stringify({ sessions }, null, 2), { sessions });
156
+ },
157
+ };
158
+ const multiclawsSessionEnd = {
159
+ name: "multiclaws_session_end",
160
+ description: "Cancel and close a collaboration session.",
161
+ parameters: {
162
+ type: "object",
163
+ additionalProperties: false,
164
+ properties: {
165
+ sessionId: { type: "string" },
166
+ },
167
+ required: ["sessionId"],
168
+ },
169
+ execute: async (_toolCallId, args) => {
170
+ const service = requireService(getService());
171
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : "";
172
+ if (!sessionId)
173
+ throw new Error("sessionId is required");
174
+ const ok = service.endSession(sessionId);
175
+ return textResult(ok ? `Session ${sessionId} ended.` : `Session ${sessionId} not found.`);
132
176
  },
133
177
  };
134
178
  /* ── Team tools ───────────────────────────────────────────────── */
@@ -280,8 +324,10 @@ function createTools(getService) {
280
324
  multiclawsAgents,
281
325
  multiclawsAddAgent,
282
326
  multiclawsRemoveAgent,
283
- multiclawsDelegate,
284
- multiclawsTaskStatus,
327
+ multiclawsSessionStart,
328
+ multiclawsSessionReply,
329
+ multiclawsSessionStatus,
330
+ multiclawsSessionEnd,
285
331
  multiclawsTeamCreate,
286
332
  multiclawsTeamJoin,
287
333
  multiclawsTeamLeave,
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import { type AgentRecord } from "./agent-registry";
3
3
  import { type AgentProfile } from "./agent-profile";
4
4
  import { type TeamRecord, type TeamMember } from "../team/team-store";
5
+ import { type ConversationSession } from "./session-store";
5
6
  import type { GatewayConfig } from "../infra/gateway-client";
6
7
  export type MulticlawsServiceOptions = {
7
8
  stateDir: string;
@@ -16,10 +17,14 @@ export type MulticlawsServiceOptions = {
16
17
  debug?: (message: string) => void;
17
18
  };
18
19
  };
19
- export type DelegateTaskResult = {
20
- taskId?: string;
21
- output?: string;
22
- status: string;
20
+ export type SessionStartResult = {
21
+ sessionId: string;
22
+ status: "running" | "failed";
23
+ error?: string;
24
+ };
25
+ export type SessionReplyResult = {
26
+ sessionId: string;
27
+ status: "ok" | "failed";
23
28
  error?: string;
24
29
  };
25
30
  export declare class MulticlawsService extends EventEmitter {
@@ -30,6 +35,7 @@ export declare class MulticlawsService extends EventEmitter {
30
35
  private readonly teamStore;
31
36
  private readonly profileStore;
32
37
  private readonly taskTracker;
38
+ private readonly sessionStore;
33
39
  private agentExecutor;
34
40
  private a2aRequestHandler;
35
41
  private agentCard;
@@ -47,11 +53,20 @@ export declare class MulticlawsService extends EventEmitter {
47
53
  apiKey?: string;
48
54
  }): Promise<AgentRecord>;
49
55
  removeAgent(url: string): Promise<boolean>;
50
- delegateTask(params: {
56
+ startSession(params: {
51
57
  agentUrl: string;
52
- task: string;
53
- }): Promise<DelegateTaskResult>;
54
- getTaskStatus(taskId: string): import("../task/tracker").TaskRecord | null;
58
+ message: string;
59
+ }): Promise<SessionStartResult>;
60
+ sendSessionMessage(params: {
61
+ sessionId: string;
62
+ message: string;
63
+ }): Promise<SessionReplyResult>;
64
+ getSession(sessionId: string): ConversationSession | null;
65
+ listSessions(): ConversationSession[];
66
+ endSession(sessionId: string): boolean;
67
+ private runSession;
68
+ private handleSessionResult;
69
+ private notifySessionUpdate;
55
70
  getProfile(): Promise<AgentProfile>;
56
71
  setProfile(patch: {
57
72
  ownerName?: string;
@@ -84,7 +99,6 @@ export declare class MulticlawsService extends EventEmitter {
84
99
  private fetchMemberDescriptions;
85
100
  private syncTeamToRegistry;
86
101
  private createA2AClient;
87
- private processTaskResult;
88
102
  private extractArtifactText;
89
103
  private notifyTailscaleSetup;
90
104
  /** Fetch with up to 2 retries and exponential backoff. */
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.MulticlawsService = void 0;
7
7
  const node_events_1 = require("node:events");
8
+ const node_crypto_1 = require("node:crypto");
8
9
  const node_os_1 = __importDefault(require("node:os"));
9
10
  const node_http_1 = __importDefault(require("node:http"));
10
11
  const node_path_1 = __importDefault(require("node:path"));
@@ -20,6 +21,7 @@ const agent_registry_1 = require("./agent-registry");
20
21
  const agent_profile_1 = require("./agent-profile");
21
22
  const team_store_1 = require("../team/team-store");
22
23
  const tracker_1 = require("../task/tracker");
24
+ const session_store_1 = require("./session-store");
23
25
  const zod_1 = require("zod");
24
26
  const gateway_client_1 = require("../infra/gateway-client");
25
27
  const rate_limiter_1 = require("../infra/rate-limiter");
@@ -34,6 +36,7 @@ class MulticlawsService extends node_events_1.EventEmitter {
34
36
  teamStore;
35
37
  profileStore;
36
38
  taskTracker;
39
+ sessionStore;
37
40
  agentExecutor = null;
38
41
  a2aRequestHandler = null;
39
42
  agentCard = null;
@@ -51,6 +54,9 @@ class MulticlawsService extends node_events_1.EventEmitter {
51
54
  this.taskTracker = new tracker_1.TaskTracker({
52
55
  filePath: node_path_1.default.join(multiclawsStateDir, "tasks.json"),
53
56
  });
57
+ this.sessionStore = new session_store_1.SessionStore({
58
+ filePath: node_path_1.default.join(multiclawsStateDir, "sessions.json"),
59
+ });
54
60
  const port = options.port ?? 3100;
55
61
  // selfUrl resolved later in start() after Tailscale detection; use placeholder for now
56
62
  this.selfUrl = options.selfUrl ?? `http://${getLocalIp()}:${port}`;
@@ -194,37 +200,208 @@ class MulticlawsService extends node_events_1.EventEmitter {
194
200
  /* ---------------------------------------------------------------- */
195
201
  /* Task delegation */
196
202
  /* ---------------------------------------------------------------- */
197
- async delegateTask(params) {
203
+ /* ---------------------------------------------------------------- */
204
+ /* Session management (multi-turn collaboration) */
205
+ /* ---------------------------------------------------------------- */
206
+ async startSession(params) {
198
207
  const agentRecord = await this.agentRegistry.get(params.agentUrl);
199
208
  if (!agentRecord) {
200
- return { status: "failed", error: `unknown agent: ${params.agentUrl}` };
209
+ return { sessionId: "", status: "failed", error: `unknown agent: ${params.agentUrl}` };
201
210
  }
202
- const track = this.taskTracker.create({
203
- fromPeerId: "local",
204
- toPeerId: params.agentUrl,
205
- task: params.task,
211
+ const contextId = (0, node_crypto_1.randomUUID)();
212
+ const session = this.sessionStore.create({
213
+ agentUrl: params.agentUrl,
214
+ agentName: agentRecord.name,
215
+ contextId,
216
+ });
217
+ this.sessionStore.appendMessage(session.sessionId, {
218
+ role: "user",
219
+ content: params.message,
220
+ timestampMs: Date.now(),
206
221
  });
207
- this.taskTracker.update(track.taskId, { status: "running" });
222
+ void this.runSession({
223
+ sessionId: session.sessionId,
224
+ agentRecord,
225
+ message: params.message,
226
+ contextId,
227
+ taskId: undefined,
228
+ });
229
+ return { sessionId: session.sessionId, status: "running" };
230
+ }
231
+ async sendSessionMessage(params) {
232
+ const session = this.sessionStore.get(params.sessionId);
233
+ if (!session) {
234
+ return { sessionId: params.sessionId, status: "failed", error: "session not found" };
235
+ }
236
+ if (session.status !== "input-required" && session.status !== "active") {
237
+ return { sessionId: params.sessionId, status: "failed", error: `session is ${session.status}, cannot send message` };
238
+ }
239
+ this.sessionStore.appendMessage(params.sessionId, {
240
+ role: "user",
241
+ content: params.message,
242
+ timestampMs: Date.now(),
243
+ });
244
+ this.sessionStore.update(params.sessionId, { status: "active" });
245
+ const agentRecord = await this.agentRegistry.get(session.agentUrl);
246
+ if (!agentRecord) {
247
+ this.sessionStore.update(params.sessionId, { status: "failed", error: "agent no longer registered" });
248
+ return { sessionId: params.sessionId, status: "failed", error: "agent no longer registered" };
249
+ }
250
+ void this.runSession({
251
+ sessionId: params.sessionId,
252
+ agentRecord,
253
+ message: params.message,
254
+ contextId: session.contextId,
255
+ taskId: session.currentTaskId,
256
+ });
257
+ return { sessionId: params.sessionId, status: "ok" };
258
+ }
259
+ getSession(sessionId) {
260
+ return this.sessionStore.get(sessionId);
261
+ }
262
+ listSessions() {
263
+ return this.sessionStore.list();
264
+ }
265
+ endSession(sessionId) {
266
+ const session = this.sessionStore.get(sessionId);
267
+ if (!session)
268
+ return false;
269
+ this.sessionStore.update(sessionId, { status: "canceled" });
270
+ return true;
271
+ }
272
+ 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);
208
276
  try {
209
- const client = await this.createA2AClient(agentRecord);
210
- const result = await client.sendMessage({
211
- message: {
212
- kind: "message",
213
- role: "user",
214
- parts: [{ kind: "text", text: params.task }],
215
- messageId: track.taskId,
216
- },
217
- });
218
- return this.processTaskResult(track.taskId, result);
277
+ 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
+ },
288
+ }),
289
+ new Promise((_, reject) => timeoutController.signal.addEventListener("abort", () => reject(new Error("session timeout")))),
290
+ ]);
291
+ await this.handleSessionResult(params.sessionId, result);
219
292
  }
220
293
  catch (err) {
221
294
  const errorMsg = err instanceof Error ? err.message : String(err);
222
- this.taskTracker.update(track.taskId, { status: "failed", error: errorMsg });
223
- return { taskId: track.taskId, status: "failed", error: errorMsg };
295
+ this.sessionStore.update(params.sessionId, { status: "failed", error: errorMsg });
296
+ await this.notifySessionUpdate(params.sessionId, "failed");
297
+ }
298
+ finally {
299
+ clearTimeout(timer);
300
+ }
301
+ }
302
+ async handleSessionResult(sessionId, result) {
303
+ // Extract content
304
+ let content = "";
305
+ let state = "completed";
306
+ let remoteTaskId;
307
+ if ("status" in result && result.status) {
308
+ const task = result;
309
+ state = task.status?.state ?? "completed";
310
+ remoteTaskId = task.id;
311
+ content = this.extractArtifactText(task);
312
+ // Also try to get text from task messages if artifacts empty
313
+ if (!content && task.history?.length) {
314
+ const lastAgentMsg = [...task.history].reverse().find((m) => m.role === "agent");
315
+ if (lastAgentMsg) {
316
+ content = lastAgentMsg.parts
317
+ ?.filter((p) => p.kind === "text")
318
+ .map((p) => p.text)
319
+ .join("\n") ?? "";
320
+ }
321
+ }
322
+ }
323
+ else {
324
+ const msg = result;
325
+ remoteTaskId = msg.taskId;
326
+ content = msg.parts
327
+ ?.filter((p) => p.kind === "text")
328
+ .map((p) => p.text)
329
+ .join("\n") ?? "";
330
+ }
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
341
+ if (state === "input-required" || state === "auth-required") {
342
+ this.sessionStore.update(sessionId, {
343
+ status: "input-required",
344
+ currentTaskId: remoteTaskId,
345
+ });
346
+ await this.notifySessionUpdate(sessionId, "input-required");
347
+ }
348
+ else if (state === "failed" || state === "rejected") {
349
+ this.sessionStore.update(sessionId, {
350
+ status: "failed",
351
+ currentTaskId: remoteTaskId,
352
+ error: content || "remote task failed",
353
+ });
354
+ await this.notifySessionUpdate(sessionId, "failed");
355
+ }
356
+ else if (state === "completed" || state === "canceled") {
357
+ this.sessionStore.update(sessionId, {
358
+ status: "completed",
359
+ currentTaskId: remoteTaskId,
360
+ });
361
+ await this.notifySessionUpdate(sessionId, "completed");
362
+ }
363
+ else {
364
+ // working / submitted / unknown — still in progress, no notification
365
+ this.sessionStore.update(sessionId, { currentTaskId: remoteTaskId });
224
366
  }
225
367
  }
226
- getTaskStatus(taskId) {
227
- return this.taskTracker.get(taskId);
368
+ async notifySessionUpdate(sessionId, event) {
369
+ if (!this.options.gatewayConfig)
370
+ return;
371
+ const session = this.sessionStore.get(sessionId);
372
+ if (!session)
373
+ return;
374
+ const lastAgentMsg = [...session.messages].reverse().find((m) => m.role === "agent");
375
+ const content = lastAgentMsg?.content ?? "";
376
+ const agentName = session.agentName;
377
+ let message;
378
+ if (event === "completed") {
379
+ message = [`✅ **${agentName} 任务完成** (session: \`${sessionId}\`)`, "", content].join("\n");
380
+ }
381
+ else if (event === "input-required") {
382
+ message = [
383
+ `📨 **${agentName} 需要补充信息** (session: \`${sessionId}\`)`,
384
+ "",
385
+ content,
386
+ "",
387
+ `→ 回复请用 \`multiclaws_session_reply\` 工具,sessionId: \`${sessionId}\``,
388
+ ].join("\n");
389
+ }
390
+ else {
391
+ const error = session.error ?? content;
392
+ message = [`❌ **${agentName} 任务失败** (session: \`${sessionId}\`)`, "", error].join("\n");
393
+ }
394
+ try {
395
+ await (0, gateway_client_1.invokeGatewayTool)({
396
+ gateway: this.options.gatewayConfig,
397
+ tool: "message",
398
+ args: { action: "send", message },
399
+ timeoutMs: 5_000,
400
+ });
401
+ }
402
+ catch {
403
+ this.log("warn", `[multiclaws] failed to notify session update: ${sessionId}`);
404
+ }
228
405
  }
229
406
  /* ---------------------------------------------------------------- */
230
407
  /* Profile */
@@ -602,27 +779,6 @@ class MulticlawsService extends node_events_1.EventEmitter {
602
779
  async createA2AClient(agent) {
603
780
  return await this.clientFactory.createFromUrl(agent.url);
604
781
  }
605
- processTaskResult(trackId, result) {
606
- if ("status" in result && result.status) {
607
- const task = result;
608
- const state = task.status?.state ?? "unknown";
609
- const output = this.extractArtifactText(task);
610
- if (state === "completed") {
611
- this.taskTracker.update(trackId, { status: "completed", result: output });
612
- }
613
- else if (state === "failed") {
614
- this.taskTracker.update(trackId, { status: "failed", error: output || "remote task failed" });
615
- }
616
- return { taskId: task.id, output, status: state };
617
- }
618
- const msg = result;
619
- const text = msg.parts
620
- ?.filter((p) => p.kind === "text")
621
- .map((p) => p.text)
622
- .join("\n") ?? "";
623
- this.taskTracker.update(trackId, { status: "completed", result: text });
624
- return { taskId: trackId, output: text, status: "completed" };
625
- }
626
782
  extractArtifactText(task) {
627
783
  if (!task.artifacts?.length)
628
784
  return "";
@@ -0,0 +1,43 @@
1
+ export type SessionStatus = "active" | "input-required" | "completed" | "failed" | "canceled";
2
+ export type SessionMessage = {
3
+ role: "user" | "agent";
4
+ content: string;
5
+ timestampMs: number;
6
+ taskId?: string;
7
+ };
8
+ export type ConversationSession = {
9
+ sessionId: string;
10
+ agentUrl: string;
11
+ agentName: string;
12
+ contextId: string;
13
+ currentTaskId?: string;
14
+ status: SessionStatus;
15
+ messages: SessionMessage[];
16
+ createdAtMs: number;
17
+ updatedAtMs: number;
18
+ error?: string;
19
+ };
20
+ export declare class SessionStore {
21
+ private readonly filePath;
22
+ private readonly ttlMs;
23
+ private store;
24
+ private persistPending;
25
+ constructor(opts: {
26
+ filePath: string;
27
+ ttlMs?: number;
28
+ });
29
+ create(params: {
30
+ agentUrl: string;
31
+ agentName: string;
32
+ contextId: string;
33
+ }): ConversationSession;
34
+ get(sessionId: string): ConversationSession | null;
35
+ list(): ConversationSession[];
36
+ update(sessionId: string, patch: Partial<Omit<ConversationSession, "sessionId" | "createdAtMs">>): ConversationSession | null;
37
+ appendMessage(sessionId: string, msg: SessionMessage): ConversationSession | null;
38
+ private loadSync;
39
+ private schedulePersist;
40
+ private persistAsync;
41
+ private prune;
42
+ private evictOldest;
43
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SessionStore = void 0;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const promises_1 = __importDefault(require("node:fs/promises"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
12
+ const MAX_SESSIONS = 1_000;
13
+ function emptyStore() {
14
+ return { version: 1, sessions: [] };
15
+ }
16
+ class SessionStore {
17
+ filePath;
18
+ ttlMs;
19
+ store;
20
+ persistPending = false;
21
+ constructor(opts) {
22
+ this.filePath = opts.filePath;
23
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
24
+ this.store = this.loadSync();
25
+ }
26
+ create(params) {
27
+ this.prune();
28
+ const now = Date.now();
29
+ const session = {
30
+ sessionId: (0, node_crypto_1.randomUUID)(),
31
+ agentUrl: params.agentUrl,
32
+ agentName: params.agentName,
33
+ contextId: params.contextId,
34
+ status: "active",
35
+ messages: [],
36
+ createdAtMs: now,
37
+ updatedAtMs: now,
38
+ };
39
+ if (this.store.sessions.length >= MAX_SESSIONS) {
40
+ this.evictOldest();
41
+ }
42
+ this.store.sessions.push(session);
43
+ this.schedulePersist();
44
+ return session;
45
+ }
46
+ get(sessionId) {
47
+ return this.store.sessions.find((s) => s.sessionId === sessionId) ?? null;
48
+ }
49
+ list() {
50
+ return [...this.store.sessions].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
51
+ }
52
+ update(sessionId, patch) {
53
+ const idx = this.store.sessions.findIndex((s) => s.sessionId === sessionId);
54
+ if (idx < 0)
55
+ return null;
56
+ const next = {
57
+ ...this.store.sessions[idx],
58
+ ...patch,
59
+ updatedAtMs: Date.now(),
60
+ };
61
+ this.store.sessions[idx] = next;
62
+ this.schedulePersist();
63
+ return next;
64
+ }
65
+ appendMessage(sessionId, msg) {
66
+ const session = this.get(sessionId);
67
+ if (!session)
68
+ return null;
69
+ return this.update(sessionId, {
70
+ messages: [...session.messages, msg],
71
+ });
72
+ }
73
+ loadSync() {
74
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.filePath), { recursive: true });
75
+ try {
76
+ 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;
80
+ }
81
+ catch {
82
+ const store = emptyStore();
83
+ node_fs_1.default.writeFileSync(this.filePath, JSON.stringify(store, null, 2), "utf8");
84
+ return store;
85
+ }
86
+ }
87
+ schedulePersist() {
88
+ if (this.persistPending)
89
+ return;
90
+ this.persistPending = true;
91
+ queueMicrotask(() => {
92
+ this.persistPending = false;
93
+ void this.persistAsync();
94
+ });
95
+ }
96
+ async persistAsync() {
97
+ try {
98
+ await promises_1.default.mkdir(node_path_1.default.dirname(this.filePath), { recursive: true });
99
+ const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
100
+ await promises_1.default.writeFile(tmp, JSON.stringify(this.store, null, 2), "utf8");
101
+ await promises_1.default.rename(tmp, this.filePath);
102
+ }
103
+ catch {
104
+ // best-effort
105
+ }
106
+ }
107
+ prune() {
108
+ const cutoff = Date.now() - this.ttlMs;
109
+ this.store.sessions = this.store.sessions.filter((s) => {
110
+ if (s.updatedAtMs >= cutoff)
111
+ return true;
112
+ return s.status !== "completed" && s.status !== "failed" && s.status !== "canceled";
113
+ });
114
+ }
115
+ evictOldest() {
116
+ const removable = [...this.store.sessions]
117
+ .filter((s) => s.status === "completed" || s.status === "failed" || s.status === "canceled")
118
+ .sort((a, b) => a.updatedAtMs - b.updatedAtMs)
119
+ .slice(0, Math.max(1, Math.floor(MAX_SESSIONS / 4)));
120
+ const ids = new Set(removable.map((s) => s.sessionId));
121
+ this.store.sessions = this.store.sessions.filter((s) => !ids.has(s.sessionId));
122
+ }
123
+ }
124
+ exports.SessionStore = SessionStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiclaws",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "MultiClaws plugin for OpenClaw collaboration via A2A protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,87 +1,111 @@
1
1
  # MultiClaws — 技能行为
2
2
 
3
- 以下规则在插件安装后生效,指导 AI 如何使用 MultiClaws 工具。
4
-
5
- MultiClaws 让多个 OpenClaw 实例通过 [A2A(Agent-to-Agent)](https://google.github.io/A2A/) 协议组成团队、互相委派任务。
3
+ MultiClaws 让多个 OpenClaw 实例通过 [A2A(Agent-to-Agent)](https://google.github.io/A2A/) 协议组成团队、互相委派和协作任务。
6
4
 
7
5
  ---
8
6
 
9
7
  ## 1. 首次安装:档案初始化(由插件 hook 触发)
10
8
 
11
9
  首次安装后,插件会通过 `before_prompt_build` hook 自动在系统提示中注入初始化任务。
12
- **无需每次对话手动检查 `multiclaws_profile_pending_review()`**,hook 已处理触发时机。
13
10
 
14
11
  当 hook 注入了初始化任务时,按以下步骤执行:
15
12
 
16
- 1. **扫描当前环境**,自动生成 bio(markdown 格式),包含:
17
- - 可用的工具和 skills(推断能力)
18
- - 已连接的渠道(Telegram、Discord、Gmail 等)
19
- - 工作区内容(git 仓库、关键文件、项目目录)
20
- - 已安装的插件
21
- - 时区、语言等上下文
13
+ 1. **扫描当前环境**,自动生成 bio(markdown 格式)
14
+ 2. 向用户展示,逐一确认:
15
+ - **名字**:询问是否需要修改(需用户明确回答)
16
+ - **Bio**:询问是否需要修改(需用户明确回答)
17
+ - **网络情况**:告知「同局域网开箱即用;跨网络需安装 Tailscale」,无需用户回答
18
+ 3. 调用 `multiclaws_profile_set(ownerName="...", bio="...")` 保存
19
+ 4. 调用 `multiclaws_profile_clear_pending_review()` 完成初始化
22
20
 
23
- 2. 向用户展示生成的档案,并逐一确认以下三项:
24
- - **名字**:展示推断出的名字,询问是否需要修改(需用户明确回答)
25
- - **Bio**:展示生成的 bio,询问是否需要修改(需用户明确回答)
26
- - **网络情况**:告知用户「同局域网开箱即用;跨网络需安装 Tailscale(https://tailscale.com/download)并重启 OpenClaw」,无需用户回答
21
+ ---
27
22
 
28
- 3. 根据用户对名字和 bio 的回答更新内容后,调用 `multiclaws_profile_set(ownerName="...", bio="...")` 保存档案。
23
+ ## 2. 协作任务(Session)
29
24
 
30
- 4. 调用 `multiclaws_profile_clear_pending_review()` 完成初始化。
25
+ **所有委派任务均通过 session 进行**,支持单轮和多轮场景。
31
26
 
32
- **示例 bio:**
33
- ```markdown
34
- 后端工程师,负责 API 服务开发与维护。
27
+ ### 开始协作
28
+ ```
29
+ multiclaws_session_start(agentUrl="...", message="任务描述")
30
+ → 立即返回 sessionId,任务在后台运行
31
+ → 完成后自动推送消息通知
32
+ ```
35
33
 
36
- **可处理:**
37
- - 代码审查、调试、重构(Node.js / Go / Python)
38
- - API 文档编写与接口设计
39
- - 数据库查询与优化(PostgreSQL)
34
+ ### 远端需要补充信息(input-required)
35
+ 收到 `📨 AgentName 需要补充信息` 通知后:
36
+ ```
37
+ multiclaws_session_reply(sessionId="...", message="补充内容")
38
+ → 继续会话,后台处理,完成后推送通知
39
+ ```
40
40
 
41
- **数据访问:**
42
- - Codebase: `/Users/eric/Project/api-service`(Node.js,~50k LOC)
43
- - Email: Gmail
44
- - Calendar: Google Calendar
41
+ ### 查看会话状态
42
+ ```
43
+ multiclaws_session_status() → 列出所有会话
44
+ multiclaws_session_status(sessionId="...") 查看单个会话及消息历史
45
+ ```
45
46
 
46
- **时区:** GMT+8
47
+ ### 结束会话
48
+ ```
49
+ multiclaws_session_end(sessionId="...") → 取消并关闭会话
47
50
  ```
48
51
 
49
- ---
52
+ ### 并发协作
53
+ 可同时开启多个 session,各自独立运行:
54
+ ```
55
+ multiclaws_session_start(agentUrl=B, message="任务1") → sessionId_1
56
+ multiclaws_session_start(agentUrl=C, message="任务2") → sessionId_2
57
+ ```
58
+
59
+ ### 链式协作(A→B→C)
60
+ B 内部可以自己调用 `multiclaws_session_start` 委派给 C,结果自然冒泡回 A。
50
61
 
51
- ## 2. 团队操作前检查档案
62
+ ---
52
63
 
53
- **创建团队** 或 **加入团队** 之前:
64
+ ## 3. 智能委派流程
54
65
 
55
66
  ```
56
- multiclaws_profile_show()
67
+ 1. multiclaws_team_members() → 列出所有成员,读 bio
68
+ 2. 选择 bio 最匹配任务的 agent
69
+ 3. multiclaws_session_start(agentUrl, message)
70
+ 4. 等待推送通知(或用 session_status 查进度)
71
+ 5. 如收到 input-required 通知 → multiclaws_session_reply 回复
57
72
  ```
58
73
 
59
- 如果 `bio` 为空或 `ownerName` 为空:
60
- 1. 自动生成 bio(同上)
61
- 2. 询问用户确认名字和 bio
62
- 3. 调用 `multiclaws_profile_set(...)` 设置
63
- 4. 然后继续团队操作
74
+ **选择 agent 原则:**
75
+ - 匹配任务领域(「财务报告」→ 有财务技能的 agent)
76
+ - 匹配数据访问(「检查代码」→ bio 中有该代码库的 agent)
77
+ - 多个匹配时选最具体的
64
78
 
65
79
  ---
66
80
 
67
- ## 3. 保持档案更新
81
+ ## 4. 团队操作前检查档案
68
82
 
69
- 档案是动态的。在以下情况自动更新(调用 `multiclaws_profile_set`):
70
- - 用户连接了新渠道或数据源
71
- - 用户安装了新 skill 或插件
72
- - 用户的角色或关注点发生变化
83
+ 在创建或加入团队之前:
73
84
 
74
- 不要等用户说「更新档案」,主动更新并简要提及即可。
85
+ ```
86
+ multiclaws_profile_show()
87
+ ```
88
+
89
+ 如果 `bio` 为空或 `ownerName` 为空,先完成档案设置再继续。
75
90
 
76
91
  ---
77
92
 
78
93
  ## 工具列表
79
94
 
95
+ ### 协作 Session
96
+
97
+ | 工具 | 说明 | 参数 |
98
+ |------|------|------|
99
+ | `multiclaws_session_start` | 开始协作会话(替代旧 delegate) | `agentUrl`, `message` |
100
+ | `multiclaws_session_reply` | 在会话中发送后续消息 | `sessionId`, `message` |
101
+ | `multiclaws_session_status` | 查看会话状态和消息历史 | `sessionId`(可选,不传返回全部) |
102
+ | `multiclaws_session_end` | 取消/关闭会话 | `sessionId` |
103
+
80
104
  ### 档案
81
105
 
82
106
  | 工具 | 说明 | 参数 |
83
107
  |------|------|------|
84
- | `multiclaws_profile_set` | 设置名字和 bio | `ownerName`(可选), `bio`(可选,markdown) |
108
+ | `multiclaws_profile_set` | 设置名字和 bio | `ownerName`(可选), `bio`(可选) |
85
109
  | `multiclaws_profile_show` | 查看当前档案 | — |
86
110
  | `multiclaws_profile_pending_review` | 检查是否有待确认的首次档案 | — |
87
111
  | `multiclaws_profile_clear_pending_review` | 清除待确认标记 | — |
@@ -93,75 +117,32 @@ multiclaws_profile_show()
93
117
  | `multiclaws_team_create` | 创建团队,返回邀请码 | `name` |
94
118
  | `multiclaws_team_join` | 用邀请码加入团队 | `inviteCode` |
95
119
  | `multiclaws_team_leave` | 离开团队 | `teamId`(可选) |
96
- | `multiclaws_team_members` | 列出团队成员 | `teamId`(可选) |
120
+ | `multiclaws_team_members` | 列出所有团队和成员 | `teamId`(可选) |
97
121
 
98
- ### 智能体与委派
122
+ ### 智能体
99
123
 
100
124
  | 工具 | 说明 | 参数 |
101
125
  |------|------|------|
102
- | `multiclaws_agents` | 列出所有已知智能体及 bio | — |
103
- | `multiclaws_add_agent` | 手动添加远端智能体 | `url`, `apiKey`(可选) |
104
- | `multiclaws_remove_agent` | 移除已知智能体 | `url` |
105
- | `multiclaws_delegate` | 委派任务给远端智能体 | `agentUrl`, `task` |
106
- | `multiclaws_task_status` | 查看委派任务状态 | `taskId` |
126
+ | `multiclaws_agents` | 列出已知 agent 及 bio | — |
127
+ | `multiclaws_add_agent` | 手动添加 agent | `url`, `apiKey`(可选) |
128
+ | `multiclaws_remove_agent` | 移除 agent | `url` |
107
129
 
108
130
  ---
109
131
 
110
132
  ## 重要规则
111
133
 
112
- - **不要问用户 IP 地址或 selfUrl。** 插件自动处理。
113
- - **只使用上面列出的工具。** 没有 `multiclaws_status` 工具。
114
- - **Bio 是自由格式的 markdown。** 写得让另一个 AI 能读懂这个智能体能做什么。
115
- - **每个智能体就像一个 skill。** 委派时读每个智能体的 bio,选最匹配的。
116
- - **名字和 bio 必须用户明确确认**;网络情况仅告知,无需用户回答。
117
-
118
- ---
119
-
120
- ## 工作流
121
-
122
- ### 创建团队
123
-
124
- ```
125
- 1. multiclaws_profile_show() — 检查档案
126
- 2.(如果为空)自动生成并设置 bio,确认名字和 bio
127
- 3. multiclaws_team_create(name="...") — 返回 inviteCode (mc:xxxx)
128
- 4. 告诉用户把邀请码分享给队友
129
- ```
130
-
131
- ### 加入团队
132
-
133
- ```
134
- 1. multiclaws_profile_show() — 检查档案
135
- 2.(如果为空)自动生成并设置 bio,确认名字和 bio
136
- 3. multiclaws_team_join(inviteCode="mc:xxxx")
137
- → 自动同步所有团队成员
138
- ```
139
-
140
- ### 智能委派
141
-
142
- ```
143
- 1. multiclaws_agents() — 列出智能体,读 bio
144
- 2. 选择 bio 最匹配任务的智能体
145
- 3. multiclaws_delegate(agentUrl="...", task="...")
146
- 4. 把结果返回给用户
147
- ```
148
-
149
- 选择智能体时:
150
- - 匹配任务领域和 bio(如「财务报告」→ 有财务技能的智能体)
151
- - 匹配数据需求(如「检查 API 代码」→ bio 中有该代码库的智能体)
152
- - 多个匹配时选最具体的
134
+ - **不要问用户 IP selfUrl**,插件自动处理
135
+ - **Bio markdown**,写得让另一个 AI 能读懂这个 agent 能做什么
136
+ - **名字和 bio 必须用户明确确认**;网络情况仅告知
137
+ - **session 超时默认 5 分钟**,超时后 status 变 failed 并推送通知
153
138
 
154
139
  ---
155
140
 
156
141
  ## 跨网络
157
142
 
158
- **同局域网:** 开箱即用,无需配置。
159
-
160
- **不同网络:** 每人安装 [Tailscale](https://tailscale.com/download) 并登录同一 tailnet,插件自动检测 Tailscale IP。
143
+ **同局域网:** 开箱即用。
161
144
 
162
- 当加入或委派因网络错误失败时(ECONNREFUSED、ETIMEDOUT、fetch failed),引导用户:
145
+ **不同网络:** 安装 [Tailscale](https://tailscale.com/download) 并登录同一 tailnet,插件自动检测。
163
146
 
164
- > 你和对方不在同一网络。跨网络协作需要每个成员安装 Tailscale:
165
- > 1. 下载安装:https://tailscale.com/download
166
- > 2. 登录同一个 Tailscale 账号(或同一 tailnet)
167
- > 3. 重启 OpenClaw,插件会自动检测 Tailscale IP
147
+ 网络错误时引导用户:
148
+ > 跨网络需要安装 Tailscale:https://tailscale.com/download,登录后重启 OpenClaw。