@alfe.ai/openclaw-chat 0.0.19 → 0.0.21

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/plugin.d.cts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin.d.ts CHANGED
@@ -225,6 +225,13 @@ interface PluginApi {
225
225
  config?: Record<string, unknown>;
226
226
  runtime?: PluginRuntime;
227
227
  registerChannel(channel: ReturnType<typeof createAlfeChannelPlugin>): void;
228
+ registerTool?(tool: {
229
+ name: string;
230
+ description: string;
231
+ label: string;
232
+ parameters: Record<string, unknown>;
233
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
234
+ }): void;
228
235
  registerGatewayMethod?(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
229
236
  on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
230
237
  priority?: number;
package/dist/plugin2.cjs CHANGED
@@ -151,7 +151,7 @@ function isAlfeSessionKey(key) {
151
151
  }
152
152
  /**
153
153
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
154
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
155
  */
156
156
  function extractChannelMode(conversationId, fallback = "chat") {
157
157
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +181,6 @@ function sessionPath(sessionId) {
181
181
  }
182
182
  async function cleanupOldSessions() {
183
183
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
184
  try {
186
185
  const jsonFiles = (await (0, node_fs_promises.readdir)(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
186
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +210,7 @@ async function cleanupOldSessions() {
211
210
  remaining--;
212
211
  } catch {}
213
212
  }
213
+ lastCleanupAt = Date.now();
214
214
  } catch {}
215
215
  }
216
216
  async function getSession(sessionId) {
@@ -242,17 +242,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
242
  cleanupOldSessions();
243
243
  return session;
244
244
  }
245
+ const writeLocks = /* @__PURE__ */ new Map();
245
246
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
247
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
248
+ const session = await getSession(sessionId);
249
+ if (!session) return;
250
+ session.messages.push({
251
+ role,
252
+ content,
253
+ timestamp: Date.now(),
254
+ ...senderId ? { senderId } : {},
255
+ ...senderName ? { senderName } : {}
256
+ });
257
+ await saveSession(session);
258
+ }).finally(() => {
259
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
260
  });
255
- await saveSession(session);
261
+ writeLocks.set(sessionId, next);
262
+ await next;
256
263
  }
257
264
  async function listSessions(filters, limit = 50) {
258
265
  await ensureDir();
@@ -289,6 +296,112 @@ async function listSessions(filters, limit = 50) {
289
296
  return summaries.slice(0, limit);
290
297
  }
291
298
  //#endregion
299
+ //#region src/a2a-tools.ts
300
+ function ok(result) {
301
+ return { content: [{
302
+ type: "text",
303
+ text: JSON.stringify(result)
304
+ }] };
305
+ }
306
+ function errResult(message) {
307
+ return { content: [{
308
+ type: "text",
309
+ text: JSON.stringify({ error: message })
310
+ }] };
311
+ }
312
+ function defineTool(def) {
313
+ return {
314
+ name: def.name,
315
+ description: def.description,
316
+ label: def.name,
317
+ parameters: def.parameters,
318
+ execute: async (_toolCallId, params) => {
319
+ try {
320
+ return ok(await def.handler(params));
321
+ } catch (e) {
322
+ return errResult(e.message);
323
+ }
324
+ }
325
+ };
326
+ }
327
+ function buildA2ATools(chatClient, log) {
328
+ return [
329
+ defineTool({
330
+ name: "list_agents",
331
+ description: "List other agents in your organization that you can communicate with.",
332
+ parameters: {
333
+ type: "object",
334
+ properties: {},
335
+ required: []
336
+ },
337
+ handler: async () => {
338
+ log.info("list_agents tool called");
339
+ return await chatClient.sendRequest("a2a.list-agents", {});
340
+ }
341
+ }),
342
+ defineTool({
343
+ name: "message_agent",
344
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ agent_id: {
349
+ type: "string",
350
+ description: "Target agent ID (use list_agents to find available agents)"
351
+ },
352
+ message: {
353
+ type: "string",
354
+ description: "Message to send to the agent"
355
+ },
356
+ thread_id: {
357
+ type: "string",
358
+ description: "Continue an existing conversation thread (omit to start a new one)"
359
+ },
360
+ max_depth: {
361
+ type: "number",
362
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
363
+ }
364
+ },
365
+ required: ["agent_id", "message"]
366
+ },
367
+ handler: async (params) => {
368
+ const agentId = params.agent_id;
369
+ const message = params.message;
370
+ const threadId = params.thread_id;
371
+ const maxDepth = params.max_depth ?? 10;
372
+ if (!agentId || !message) throw new Error("agent_id and message are required");
373
+ log.info(`message_agent tool: sending to ${agentId}`);
374
+ return await chatClient.sendRequest("a2a.send", {
375
+ targetAgentId: agentId,
376
+ message,
377
+ threadId,
378
+ maxDepth
379
+ });
380
+ }
381
+ }),
382
+ defineTool({
383
+ name: "end_conversation",
384
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: { summary: {
388
+ type: "string",
389
+ description: "Brief summary of what was discussed or resolved"
390
+ } },
391
+ required: []
392
+ },
393
+ handler: (params) => {
394
+ const summary = params.summary;
395
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
396
+ return Promise.resolve({
397
+ status: "conversation_ended",
398
+ summary
399
+ });
400
+ }
401
+ })
402
+ ];
403
+ }
404
+ //#endregion
292
405
  //#region src/plugin.ts
293
406
  /**
294
407
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -305,17 +418,29 @@ async function listSessions(filters, limit = 50) {
305
418
  */
306
419
  let dispatchInbound = null;
307
420
  /**
308
- * Resolve OpenClaw SDK functions from the global install.
309
- * The openclaw package is installed globally (/usr/lib/node_modules/openclaw/)
310
- * but is NOT in the plugin's node_modules. Built-in extensions can import
311
- * directly; external plugins must use createRequire.
421
+ * Resolve OpenClaw SDK functions from the running process.
422
+ *
423
+ * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
424
+ * Since the plugin runs inside OpenClaw's process, we anchor resolution to
425
+ * OpenClaw's own entry module via require.main, then fall back to deriving
426
+ * the global modules path from process.execPath.
312
427
  */
313
428
  function resolveOpenClawSdk(log) {
314
- for (const globalPath of ["/usr/lib/node_modules/openclaw/package.json", "/usr/local/lib/node_modules/openclaw/package.json"]) try {
315
- const channelInbound = (0, node_module.createRequire)(globalPath)("openclaw/plugin-sdk/channel-inbound");
429
+ const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
430
+ for (const anchor of anchors) try {
431
+ const channelInbound = (0, node_module.createRequire)(anchor)("openclaw/plugin-sdk/channel-inbound");
432
+ if (channelInbound.dispatchInboundDirectDmWithRuntime) {
433
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
434
+ log.info(`Resolved OpenClaw SDK from ${anchor}`);
435
+ return;
436
+ }
437
+ } catch {}
438
+ try {
439
+ const derivedPath = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
440
+ const channelInbound = (0, node_module.createRequire)(derivedPath)("openclaw/plugin-sdk/channel-inbound");
316
441
  if (channelInbound.dispatchInboundDirectDmWithRuntime) {
317
442
  dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
318
- log.info(`Resolved OpenClaw SDK from ${globalPath}`);
443
+ log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
319
444
  return;
320
445
  }
321
446
  } catch {}
@@ -325,20 +450,30 @@ let pluginRuntime = null;
325
450
  let chatClient = null;
326
451
  let connectingPromise = null;
327
452
  let metricsClient = null;
453
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
454
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
328
455
  async function downloadAttachments(attachments, log) {
329
456
  const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
330
457
  await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
331
458
  const results = [];
332
459
  for (const att of attachments) {
333
- const filename = att.filename ?? att.id;
460
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
334
461
  const localPath = (0, node_path.join)(attachDir, `${att.id}_${filename}`);
462
+ const controller = new AbortController();
463
+ const timeout = setTimeout(() => {
464
+ controller.abort();
465
+ }, DOWNLOAD_TIMEOUT_MS);
335
466
  try {
336
- const res = await fetch(att.url);
467
+ const res = await fetch(att.url, { signal: controller.signal });
337
468
  if (!res.ok) {
338
469
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
339
470
  continue;
340
471
  }
341
472
  const buffer = Buffer.from(await res.arrayBuffer());
473
+ if (buffer.length > MAX_FILE_SIZE) {
474
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
475
+ continue;
476
+ }
342
477
  await (0, node_fs_promises.writeFile)(localPath, buffer);
343
478
  results.push({
344
479
  localPath,
@@ -348,6 +483,8 @@ async function downloadAttachments(attachments, log) {
348
483
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
349
484
  } catch (err) {
350
485
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
486
+ } finally {
487
+ clearTimeout(timeout);
351
488
  }
352
489
  }
353
490
  return results;
@@ -362,7 +499,8 @@ async function handleAgentRequest(request, log) {
362
499
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
363
500
  return;
364
501
  }
365
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments } = request.params;
502
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
503
+ const isA2A = !!a2a;
366
504
  if (!message && !rawAttachments?.length) {
367
505
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
368
506
  return;
@@ -389,11 +527,13 @@ async function handleAgentRequest(request, log) {
389
527
  try {
390
528
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
391
529
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
392
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
393
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
530
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
531
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
394
532
  const shortConvId = conversationId?.slice(-8) ?? "";
395
533
  const userLabel = displayName ?? userId ?? senderId;
396
534
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
535
+ let a2aResponseBuffer = "";
536
+ let a2aResolved = false;
397
537
  resolvedOpenClawKey = (await dispatchInbound({
398
538
  cfg,
399
539
  runtime: { channel: runtime.channel },
@@ -420,12 +560,29 @@ async function handleAgentRequest(request, log) {
420
560
  ...clientType ? { ClientType: clientType } : {},
421
561
  ...conversationId ? { ConversationId: conversationId } : {},
422
562
  ...displayName ? { SenderName: displayName } : {},
423
- ChannelMode: channelMode
563
+ ChannelMode: channelMode,
564
+ ...isA2A ? {
565
+ CallerType: "agent",
566
+ CallerAgentId: a2a.sourceAgentId,
567
+ CallerAgentName: a2a.sourceAgentName,
568
+ InteractionDepth: String(a2a.depth),
569
+ A2ASystemPrompt: [
570
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
571
+ "Rules:",
572
+ "- Only respond if you have new information, a question, or an action to coordinate.",
573
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
574
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
575
+ ].join("\n")
576
+ } : {}
424
577
  },
425
578
  deliver: async (payload) => {
426
579
  const responseText = payload.text ?? "";
427
580
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
428
581
  await addMessage(sessionId, "assistant", responseText);
582
+ if (isA2A) {
583
+ a2aResponseBuffer += responseText;
584
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
585
+ }
429
586
  chatClient?.notify("agent-message", {
430
587
  conversationId: conversationId ?? legacySessionKey,
431
588
  text: responseText,
@@ -437,7 +594,7 @@ async function handleAgentRequest(request, log) {
437
594
  channel: "alfe",
438
595
  role: "assistant"
439
596
  }).catch((err) => {
440
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
597
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
441
598
  });
442
599
  },
443
600
  onRecordError: (err) => {
@@ -447,6 +604,12 @@ async function handleAgentRequest(request, log) {
447
604
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
448
605
  }
449
606
  })).route.sessionKey;
607
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
608
+ conversationId: conversationId ?? legacySessionKey,
609
+ fullText: a2aResponseBuffer,
610
+ a2a,
611
+ resolved: a2aResolved
612
+ });
450
613
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
451
614
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
452
615
  } catch (err) {
@@ -516,10 +679,15 @@ const plugin = {
516
679
  activate(api) {
517
680
  const log = api.logger;
518
681
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
519
- globalThis.__alfeChatPluginActivated = true;
682
+ const isGatewayMode = !!api.runtime;
520
683
  const alfeChannel = createAlfeChannelPlugin();
521
684
  api.registerChannel(alfeChannel);
522
685
  log.info(`Registered channel: ${alfeChannel.id}`);
686
+ if (!isGatewayMode) {
687
+ log.debug("Management command context — skipping persistent resource init");
688
+ return;
689
+ }
690
+ globalThis.__alfeChatPluginActivated = true;
523
691
  if (!alreadyActivated) {
524
692
  log.info("Chat plugin registering...");
525
693
  resolveOpenClawSdk(log);
@@ -547,10 +715,17 @@ const plugin = {
547
715
  wsUrl: chatWsUrl,
548
716
  apiKey,
549
717
  onRequest: (request) => {
550
- if (request.method === "agent") handleAgentRequest(request, log);
551
- else if (request.method === "sessions.list") handleSessionsList(request, log);
552
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
553
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
718
+ const handle = async () => {
719
+ if (request.method === "agent") await handleAgentRequest(request, log);
720
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
721
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
722
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
723
+ };
724
+ handle().catch((err) => {
725
+ const errMsg = err instanceof Error ? err.message : String(err);
726
+ log.error(`Request handler crashed: ${errMsg}`);
727
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
728
+ });
554
729
  },
555
730
  onConnectionChange: (connected) => {
556
731
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -599,20 +774,30 @@ const plugin = {
599
774
  });
600
775
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
601
776
  }
777
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
778
+ if (chatClient && typeof api.registerTool === "function") {
779
+ const a2aTools = buildA2ATools(chatClient, log);
780
+ for (const tool of a2aTools) api.registerTool(tool);
781
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
782
+ }
783
+ }).catch(() => {});
602
784
  if (!alreadyActivated) {
603
785
  api.on("session_start", async (...eventArgs) => {
786
+ if (!pluginRuntime) return;
604
787
  const key = eventArgs[0].sessionKey;
605
788
  if (!key || isAlfeSessionKey(key)) return;
606
789
  log.info(`Chat session starting: ${key}`);
607
790
  await createSession(key, "", "alfe");
608
791
  }, { priority: 50 });
609
792
  api.on("message_received", async (...eventArgs) => {
793
+ if (!pluginRuntime) return;
610
794
  const event = eventArgs[0];
611
795
  const ctx = eventArgs[1];
612
796
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
613
797
  await addMessage(ctx.conversationId, "user", event.content, event.from);
614
798
  });
615
799
  api.on("session_end", (...eventArgs) => {
800
+ if (!pluginRuntime) return;
616
801
  const key = eventArgs[0].sessionKey;
617
802
  if (!key || !isAlfeSessionKey(key)) return;
618
803
  log.info(`Chat session ending: ${key}`);
package/dist/plugin2.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
6
6
  import { resolveConfig } from "@alfe.ai/config";
7
7
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
8
8
  import { existsSync } from "node:fs";
9
+ //#region \0rolldown/runtime.js
10
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
11
+ //#endregion
9
12
  //#region src/alfe-channel.ts
10
13
  const CHANNEL_ID = "alfe";
11
14
  const DEFAULT_ACCOUNT_ID = "default";
@@ -151,7 +154,7 @@ function isAlfeSessionKey(key) {
151
154
  }
152
155
  /**
153
156
  * Extract the channel mode from a standardized session key or conversationId.
154
- * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat') or fallback.
157
+ * Returns the mode segment (e.g. 'sms', 'whatsapp', 'chat', 'a2a') or fallback.
155
158
  */
156
159
  function extractChannelMode(conversationId, fallback = "chat") {
157
160
  return /^alfe:(\w+):/.exec(conversationId)?.[1] ?? fallback;
@@ -181,7 +184,6 @@ function sessionPath(sessionId) {
181
184
  }
182
185
  async function cleanupOldSessions() {
183
186
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
184
- lastCleanupAt = Date.now();
185
187
  try {
186
188
  const jsonFiles = (await readdir(SESSIONS_DIR)).filter((f) => f.endsWith(".json"));
187
189
  if (jsonFiles.length <= MAX_SESSIONS) {
@@ -211,6 +213,7 @@ async function cleanupOldSessions() {
211
213
  remaining--;
212
214
  } catch {}
213
215
  }
216
+ lastCleanupAt = Date.now();
214
217
  } catch {}
215
218
  }
216
219
  async function getSession(sessionId) {
@@ -242,17 +245,24 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
242
245
  cleanupOldSessions();
243
246
  return session;
244
247
  }
248
+ const writeLocks = /* @__PURE__ */ new Map();
245
249
  async function addMessage(sessionId, role, content, senderId, senderName) {
246
- const session = await getSession(sessionId);
247
- if (!session) return;
248
- session.messages.push({
249
- role,
250
- content,
251
- timestamp: Date.now(),
252
- ...senderId ? { senderId } : {},
253
- ...senderName ? { senderName } : {}
250
+ const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
251
+ const session = await getSession(sessionId);
252
+ if (!session) return;
253
+ session.messages.push({
254
+ role,
255
+ content,
256
+ timestamp: Date.now(),
257
+ ...senderId ? { senderId } : {},
258
+ ...senderName ? { senderName } : {}
259
+ });
260
+ await saveSession(session);
261
+ }).finally(() => {
262
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
254
263
  });
255
- await saveSession(session);
264
+ writeLocks.set(sessionId, next);
265
+ await next;
256
266
  }
257
267
  async function listSessions(filters, limit = 50) {
258
268
  await ensureDir();
@@ -289,6 +299,112 @@ async function listSessions(filters, limit = 50) {
289
299
  return summaries.slice(0, limit);
290
300
  }
291
301
  //#endregion
302
+ //#region src/a2a-tools.ts
303
+ function ok(result) {
304
+ return { content: [{
305
+ type: "text",
306
+ text: JSON.stringify(result)
307
+ }] };
308
+ }
309
+ function errResult(message) {
310
+ return { content: [{
311
+ type: "text",
312
+ text: JSON.stringify({ error: message })
313
+ }] };
314
+ }
315
+ function defineTool(def) {
316
+ return {
317
+ name: def.name,
318
+ description: def.description,
319
+ label: def.name,
320
+ parameters: def.parameters,
321
+ execute: async (_toolCallId, params) => {
322
+ try {
323
+ return ok(await def.handler(params));
324
+ } catch (e) {
325
+ return errResult(e.message);
326
+ }
327
+ }
328
+ };
329
+ }
330
+ function buildA2ATools(chatClient, log) {
331
+ return [
332
+ defineTool({
333
+ name: "list_agents",
334
+ description: "List other agents in your organization that you can communicate with.",
335
+ parameters: {
336
+ type: "object",
337
+ properties: {},
338
+ required: []
339
+ },
340
+ handler: async () => {
341
+ log.info("list_agents tool called");
342
+ return await chatClient.sendRequest("a2a.list-agents", {});
343
+ }
344
+ }),
345
+ defineTool({
346
+ name: "message_agent",
347
+ description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
348
+ parameters: {
349
+ type: "object",
350
+ properties: {
351
+ agent_id: {
352
+ type: "string",
353
+ description: "Target agent ID (use list_agents to find available agents)"
354
+ },
355
+ message: {
356
+ type: "string",
357
+ description: "Message to send to the agent"
358
+ },
359
+ thread_id: {
360
+ type: "string",
361
+ description: "Continue an existing conversation thread (omit to start a new one)"
362
+ },
363
+ max_depth: {
364
+ type: "number",
365
+ description: "Maximum number of back-and-forth exchanges (default: 10)"
366
+ }
367
+ },
368
+ required: ["agent_id", "message"]
369
+ },
370
+ handler: async (params) => {
371
+ const agentId = params.agent_id;
372
+ const message = params.message;
373
+ const threadId = params.thread_id;
374
+ const maxDepth = params.max_depth ?? 10;
375
+ if (!agentId || !message) throw new Error("agent_id and message are required");
376
+ log.info(`message_agent tool: sending to ${agentId}`);
377
+ return await chatClient.sendRequest("a2a.send", {
378
+ targetAgentId: agentId,
379
+ message,
380
+ threadId,
381
+ maxDepth
382
+ });
383
+ }
384
+ }),
385
+ defineTool({
386
+ name: "end_conversation",
387
+ description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
388
+ parameters: {
389
+ type: "object",
390
+ properties: { summary: {
391
+ type: "string",
392
+ description: "Brief summary of what was discussed or resolved"
393
+ } },
394
+ required: []
395
+ },
396
+ handler: (params) => {
397
+ const summary = params.summary;
398
+ log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
399
+ return Promise.resolve({
400
+ status: "conversation_ended",
401
+ summary
402
+ });
403
+ }
404
+ })
405
+ ];
406
+ }
407
+ //#endregion
292
408
  //#region src/plugin.ts
293
409
  /**
294
410
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -305,17 +421,29 @@ async function listSessions(filters, limit = 50) {
305
421
  */
306
422
  let dispatchInbound = null;
307
423
  /**
308
- * Resolve OpenClaw SDK functions from the global install.
309
- * The openclaw package is installed globally (/usr/lib/node_modules/openclaw/)
310
- * but is NOT in the plugin's node_modules. Built-in extensions can import
311
- * directly; external plugins must use createRequire.
424
+ * Resolve OpenClaw SDK functions from the running process.
425
+ *
426
+ * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
427
+ * Since the plugin runs inside OpenClaw's process, we anchor resolution to
428
+ * OpenClaw's own entry module via require.main, then fall back to deriving
429
+ * the global modules path from process.execPath.
312
430
  */
313
431
  function resolveOpenClawSdk(log) {
314
- for (const globalPath of ["/usr/lib/node_modules/openclaw/package.json", "/usr/local/lib/node_modules/openclaw/package.json"]) try {
315
- const channelInbound = createRequire(globalPath)("openclaw/plugin-sdk/channel-inbound");
432
+ const anchors = [__require.main?.filename, process.argv[1]].filter(Boolean);
433
+ for (const anchor of anchors) try {
434
+ const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
435
+ if (channelInbound.dispatchInboundDirectDmWithRuntime) {
436
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
437
+ log.info(`Resolved OpenClaw SDK from ${anchor}`);
438
+ return;
439
+ }
440
+ } catch {}
441
+ try {
442
+ const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
443
+ const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
316
444
  if (channelInbound.dispatchInboundDirectDmWithRuntime) {
317
445
  dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
318
- log.info(`Resolved OpenClaw SDK from ${globalPath}`);
446
+ log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
319
447
  return;
320
448
  }
321
449
  } catch {}
@@ -325,20 +453,30 @@ let pluginRuntime = null;
325
453
  let chatClient = null;
326
454
  let connectingPromise = null;
327
455
  let metricsClient = null;
456
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
457
+ const DOWNLOAD_TIMEOUT_MS = 3e4;
328
458
  async function downloadAttachments(attachments, log) {
329
459
  const attachDir = join(homedir(), ".alfe", "attachments");
330
460
  await mkdir(attachDir, { recursive: true });
331
461
  const results = [];
332
462
  for (const att of attachments) {
333
- const filename = att.filename ?? att.id;
463
+ const filename = (att.filename ?? att.id).replace(/[/\\]/g, "_").replace(/\.\./g, "_").replace(/\0/g, "");
334
464
  const localPath = join(attachDir, `${att.id}_${filename}`);
465
+ const controller = new AbortController();
466
+ const timeout = setTimeout(() => {
467
+ controller.abort();
468
+ }, DOWNLOAD_TIMEOUT_MS);
335
469
  try {
336
- const res = await fetch(att.url);
470
+ const res = await fetch(att.url, { signal: controller.signal });
337
471
  if (!res.ok) {
338
472
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
339
473
  continue;
340
474
  }
341
475
  const buffer = Buffer.from(await res.arrayBuffer());
476
+ if (buffer.length > MAX_FILE_SIZE) {
477
+ log.warn(`Attachment ${att.id} exceeds max size (${String(buffer.length)} bytes) — skipping`);
478
+ continue;
479
+ }
342
480
  await writeFile(localPath, buffer);
343
481
  results.push({
344
482
  localPath,
@@ -348,6 +486,8 @@ async function downloadAttachments(attachments, log) {
348
486
  log.info(`Downloaded attachment: ${localPath} (${String(buffer.length)} bytes)`);
349
487
  } catch (err) {
350
488
  log.error(`Failed to download attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
489
+ } finally {
490
+ clearTimeout(timeout);
351
491
  }
352
492
  }
353
493
  return results;
@@ -362,7 +502,8 @@ async function handleAgentRequest(request, log) {
362
502
  chatClient?.sendResponse(request.id, false, { message: "OpenClaw SDK not available — cannot dispatch" });
363
503
  return;
364
504
  }
365
- const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments } = request.params;
505
+ const { message, sessionKey: legacySessionKey, userId, conversationId, conversationType, tenantId, clientType, displayName, attachments: rawAttachments, a2a } = request.params;
506
+ const isA2A = !!a2a;
366
507
  if (!message && !rawAttachments?.length) {
367
508
  chatClient?.sendResponse(request.id, false, { message: "Missing message" });
368
509
  return;
@@ -389,11 +530,13 @@ async function handleAgentRequest(request, log) {
389
530
  try {
390
531
  const downloadedFiles = rawAttachments?.length ? await downloadAttachments(rawAttachments, log) : [];
391
532
  const bodyForAgent = downloadedFiles.length ? `${message || ""}\n\n[Attached files:\n${downloadedFiles.map((f) => `- ${f.filename}: ${f.localPath}`).join("\n")}]` : void 0;
392
- const channelMode = extractChannelMode(conversationId ?? "", clientType ?? "chat");
393
- const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : "Alfe";
533
+ const channelMode = isA2A ? "a2a" : extractChannelMode(conversationId ?? "", clientType ?? "chat");
534
+ const channelLabel = channelMode === "sms" ? "SMS" : channelMode === "whatsapp" ? "WhatsApp" : channelMode === "a2a" ? "Agent" : "Alfe";
394
535
  const shortConvId = conversationId?.slice(-8) ?? "";
395
536
  const userLabel = displayName ?? userId ?? senderId;
396
537
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
538
+ let a2aResponseBuffer = "";
539
+ let a2aResolved = false;
397
540
  resolvedOpenClawKey = (await dispatchInbound({
398
541
  cfg,
399
542
  runtime: { channel: runtime.channel },
@@ -420,12 +563,29 @@ async function handleAgentRequest(request, log) {
420
563
  ...clientType ? { ClientType: clientType } : {},
421
564
  ...conversationId ? { ConversationId: conversationId } : {},
422
565
  ...displayName ? { SenderName: displayName } : {},
423
- ChannelMode: channelMode
566
+ ChannelMode: channelMode,
567
+ ...isA2A ? {
568
+ CallerType: "agent",
569
+ CallerAgentId: a2a.sourceAgentId,
570
+ CallerAgentName: a2a.sourceAgentName,
571
+ InteractionDepth: String(a2a.depth),
572
+ A2ASystemPrompt: [
573
+ `This is an agent-to-agent conversation with ${a2a.sourceAgentName}.`,
574
+ "Rules:",
575
+ "- Only respond if you have new information, a question, or an action to coordinate.",
576
+ "- If the conversation is complete, call the end_conversation() tool OR end your message with [RESOLVED].",
577
+ "- Do NOT respond just to acknowledge — that creates infinite loops."
578
+ ].join("\n")
579
+ } : {}
424
580
  },
425
581
  deliver: async (payload) => {
426
582
  const responseText = payload.text ?? "";
427
583
  const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
428
584
  await addMessage(sessionId, "assistant", responseText);
585
+ if (isA2A) {
586
+ a2aResponseBuffer += responseText;
587
+ if (responseText.includes("\"status\":\"conversation_ended\"") || responseText.includes("\"status\": \"conversation_ended\"")) a2aResolved = true;
588
+ }
429
589
  chatClient?.notify("agent-message", {
430
590
  conversationId: conversationId ?? legacySessionKey,
431
591
  text: responseText,
@@ -437,7 +597,7 @@ async function handleAgentRequest(request, log) {
437
597
  channel: "alfe",
438
598
  role: "assistant"
439
599
  }).catch((err) => {
440
- log.debug(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
600
+ log.warn(`Metrics report failed: ${err instanceof Error ? err.message : String(err)}`);
441
601
  });
442
602
  },
443
603
  onRecordError: (err) => {
@@ -447,6 +607,12 @@ async function handleAgentRequest(request, log) {
447
607
  log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
448
608
  }
449
609
  })).route.sessionKey;
610
+ if (isA2A && a2aResponseBuffer) chatClient?.notify("a2a-complete", {
611
+ conversationId: conversationId ?? legacySessionKey,
612
+ fullText: a2aResponseBuffer,
613
+ a2a,
614
+ resolved: a2aResolved
615
+ });
450
616
  chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
451
617
  log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
452
618
  } catch (err) {
@@ -516,10 +682,15 @@ const plugin = {
516
682
  activate(api) {
517
683
  const log = api.logger;
518
684
  const alreadyActivated = globalThis.__alfeChatPluginActivated === true;
519
- globalThis.__alfeChatPluginActivated = true;
685
+ const isGatewayMode = !!api.runtime;
520
686
  const alfeChannel = createAlfeChannelPlugin();
521
687
  api.registerChannel(alfeChannel);
522
688
  log.info(`Registered channel: ${alfeChannel.id}`);
689
+ if (!isGatewayMode) {
690
+ log.debug("Management command context — skipping persistent resource init");
691
+ return;
692
+ }
693
+ globalThis.__alfeChatPluginActivated = true;
523
694
  if (!alreadyActivated) {
524
695
  log.info("Chat plugin registering...");
525
696
  resolveOpenClawSdk(log);
@@ -547,10 +718,17 @@ const plugin = {
547
718
  wsUrl: chatWsUrl,
548
719
  apiKey,
549
720
  onRequest: (request) => {
550
- if (request.method === "agent") handleAgentRequest(request, log);
551
- else if (request.method === "sessions.list") handleSessionsList(request, log);
552
- else if (request.method === "sessions.get") handleSessionsGet(request, log);
553
- else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
721
+ const handle = async () => {
722
+ if (request.method === "agent") await handleAgentRequest(request, log);
723
+ else if (request.method === "sessions.list") await handleSessionsList(request, log);
724
+ else if (request.method === "sessions.get") await handleSessionsGet(request, log);
725
+ else chatClient?.sendResponse(request.id, false, { message: `Unknown method: ${request.method}` });
726
+ };
727
+ handle().catch((err) => {
728
+ const errMsg = err instanceof Error ? err.message : String(err);
729
+ log.error(`Request handler crashed: ${errMsg}`);
730
+ chatClient?.sendResponse(request.id, false, { message: "Internal error" });
731
+ });
554
732
  },
555
733
  onConnectionChange: (connected) => {
556
734
  log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
@@ -599,20 +777,30 @@ const plugin = {
599
777
  });
600
778
  log.info("Registered gateway RPC methods: sessions.list, sessions.get");
601
779
  }
780
+ if (!alreadyActivated && typeof api.registerTool === "function") connectingPromise?.then(() => {
781
+ if (chatClient && typeof api.registerTool === "function") {
782
+ const a2aTools = buildA2ATools(chatClient, log);
783
+ for (const tool of a2aTools) api.registerTool(tool);
784
+ log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
785
+ }
786
+ }).catch(() => {});
602
787
  if (!alreadyActivated) {
603
788
  api.on("session_start", async (...eventArgs) => {
789
+ if (!pluginRuntime) return;
604
790
  const key = eventArgs[0].sessionKey;
605
791
  if (!key || isAlfeSessionKey(key)) return;
606
792
  log.info(`Chat session starting: ${key}`);
607
793
  await createSession(key, "", "alfe");
608
794
  }, { priority: 50 });
609
795
  api.on("message_received", async (...eventArgs) => {
796
+ if (!pluginRuntime) return;
610
797
  const event = eventArgs[0];
611
798
  const ctx = eventArgs[1];
612
799
  if (!ctx.conversationId || ctx.channelId === "alfe") return;
613
800
  await addMessage(ctx.conversationId, "user", event.content, event.from);
614
801
  });
615
802
  api.on("session_end", (...eventArgs) => {
803
+ if (!pluginRuntime) return;
616
804
  const key = eventArgs[0].sessionKey;
617
805
  if (!key || !isAlfeSessionKey(key)) return;
618
806
  log.info(`Chat session ending: ${key}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@alfe.ai/agent-api-client": "^0.0.7",
31
- "@alfe.ai/chat": "^0.0.6",
31
+ "@alfe.ai/chat": "^0.0.7",
32
32
  "@alfe.ai/config": "^0.0.7"
33
33
  },
34
34
  "peerDependencies": {