openclaw-lark-multi-agent 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -232,6 +232,29 @@ OpenClaw-level slash commands can be sent by escaping with a double slash:
232
232
 
233
233
  The bridge converts `//status` to `/status` and forwards it to OpenClaw instead of handling it locally.
234
234
 
235
+ How to test the double-slash behavior:
236
+
237
+ 1. In a private chat with one bot, send `//status`.
238
+ - Expected: the message is forwarded to OpenClaw as `/status`.
239
+ - It should **not** be handled by the bridge-level `/status` command.
240
+ 2. In a group chat, mention a specific bot or use `@all` with a double slash:
241
+
242
+ ```text
243
+ @GPT //status
244
+ @all //reset
245
+ ```
246
+
247
+ Expected: the bridge strips the leading mention for routing, converts `//...` to `/...`, and forwards the command to OpenClaw.
248
+
249
+ To test bridge-level commands instead, use a single slash:
250
+
251
+ ```text
252
+ /reset
253
+ @all /reset
254
+ ```
255
+
256
+ Expected: the bridge handles the command locally and does not forward it to OpenClaw.
257
+
235
258
  ## Message routing rules
236
259
 
237
260
  ### Private chats
package/README.zh-CN.md CHANGED
@@ -232,6 +232,29 @@ openclaw-lark-multi-agent install-windows-service
232
232
 
233
233
  桥接层会把 `//status` 转成 `/status` 并转发给 OpenClaw,而不是自己处理。
234
234
 
235
+ 如何测试 double slash 行为:
236
+
237
+ 1. 在某个 bot 私聊里发送 `//status`。
238
+ - 预期:消息会被转成 `/status` 发给 OpenClaw。
239
+ - 它**不会**被桥接层当成本地 `/status` 命令处理。
240
+ 2. 在群聊里,先 @ 某个 bot,或者 @所有人,再加双斜杠命令:
241
+
242
+ ```text
243
+ @GPT //status
244
+ @所有人 //reset
245
+ ```
246
+
247
+ 预期:桥接层只用前面的 @ 来做路由,然后把 `//...` 转成 `/...` 发给 OpenClaw。
248
+
249
+ 如果要测试桥接层自己的命令,用单斜杠:
250
+
251
+ ```text
252
+ /reset
253
+ @所有人 /reset
254
+ ```
255
+
256
+ 预期:桥接层本地处理这个命令,不会转发给 OpenClaw。
257
+
235
258
  ## 消息路由规则
236
259
 
237
260
  ### 私聊
@@ -64,11 +64,15 @@ export declare class FeishuBot {
64
64
  */
65
65
  private processQueue;
66
66
  private processQueueInner;
67
+ private shouldHandleBridgeCommand;
67
68
  private shouldRespond;
68
69
  private isMentioned;
70
+ private isAllMention;
71
+ private mentionedBotName;
69
72
  private resolveBotName;
70
73
  private resolveHumanName;
71
74
  private cleanMentions;
75
+ private stripLeadingCommandMentions;
72
76
  private replyMessage;
73
77
  /**
74
78
  * Send a proactive message to a chat (not a reply).
@@ -302,11 +302,18 @@ export class FeishuBot {
302
302
  }
303
303
  if (!cleanText.trim())
304
304
  return;
305
+ // Commands may be prefixed by @all / @bot in group chats. Strip those
306
+ // leading routing mentions before deciding whether this is a bridge command
307
+ // or an escaped OpenClaw command.
308
+ const trimmedCleanText = cleanText.trim();
309
+ const commandText = this.stripLeadingCommandMentions(trimmedCleanText);
305
310
  // Escape hatch: //command means send /command through to OpenClaw,
306
311
  // while /command remains a bridge-level openclaw-lark-multi-agent command.
307
- const trimmedCleanText = cleanText.trim();
308
- if (trimmedCleanText.startsWith("//")) {
309
- cleanText = "/" + trimmedCleanText.slice(2);
312
+ if (commandText.startsWith("//")) {
313
+ cleanText = "/" + commandText.slice(2).trimStart();
314
+ }
315
+ else if (commandText.startsWith("/")) {
316
+ cleanText = commandText;
310
317
  }
311
318
  // --- Record to local store (ALL messages, before command/response checks) ---
312
319
  const senderName = isBot
@@ -327,11 +334,13 @@ export class FeishuBot {
327
334
  // --- Commands: in p2p always respond; in group, check shouldRespond first ---
328
335
  // Single slash commands are handled by the bridge. Double slash commands were
329
336
  // already unescaped above and should pass through to OpenClaw instead.
330
- const isBridgeCommand = !trimmedCleanText.startsWith("//");
337
+ const isBridgeCommand = !commandText.startsWith("//");
331
338
  const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free)/.test(cleanText.trim());
332
339
  if (isCommand) {
333
- // In group chats, commands also require mention/shouldRespond
334
- if (chatType !== "p2p" && !this.shouldRespond(chatType, message, isBot, chatId, message.content))
340
+ // In group chats, bridge commands must be explicitly routed to this bot
341
+ // or @all. Do not let Free Discussion make a bot execute commands meant
342
+ // for another bot.
343
+ if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content))
335
344
  return;
336
345
  const markCommandSynced = () => {
337
346
  if (insertedId > 0) {
@@ -343,12 +352,33 @@ export class FeishuBot {
343
352
  const helpText = [
344
353
  `📚 ${this.config.name} Bot 命令列表`,
345
354
  `━━━━━━━━━━━━━━━━━━`,
355
+ `桥接层命令(单斜杠,由 openclaw-lark-multi-agent 本地处理)`,
346
356
  `📊 /status — 查看当前模型、Token 用量、Session 状态`,
347
- `🧹 /compact — 压缩上下文(保留摘要,释放 token)`,
348
- `🔄 /reset — 重置会话(清空历史,从头开始)`,
349
- `🔊 /verbose — 开关 Tool Call 显示(查看 AI 调用了哪些工具)`,
350
- `🔓 /free 开关 Free Discussion(群聊中无需 @ 即可回复)`,
357
+ `🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
358
+ `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
359
+ `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
360
+ `🔓 /free [on|off|status] 开关当前 bot 在当前群聊的 Free Discussion`,
351
361
  `❓ /help — 显示此帮助信息`,
362
+ ``,
363
+ `OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
364
+ `🆕 //new — 新建/切换会话`,
365
+ `🔄 //reset — 让 OpenClaw 自己执行 reset`,
366
+ `🧹 //compact [instructions] — 让 OpenClaw 压缩上下文`,
367
+ `⏹️ //stopOptions — 查看/停止当前运行选项`,
368
+ `🧠 //think <level> — 设置思考等级`,
369
+ `🤖 //model <id> — 切换/查看模型`,
370
+ `⚡ //fast status|on|off — OpenAI fast mode`,
371
+ `🔊 //verbose on|off|full — OpenClaw verbose`,
372
+ `🧵 //trace on|off|rawStatus — OpenClaw trace`,
373
+ `📊 //status — OpenClaw 原生状态`,
374
+ `📋 //tasks — 查看任务`,
375
+ `👤 //whoami — 查看当前身份/会话`,
376
+ `🧩 //contextSkills — 查看当前上下文技能`,
377
+ `🛠️ //skill <name> [input] — 调用技能`,
378
+ `🧰 //tools — 查看可用工具`,
379
+ `📖 //commands — 查看完整 OpenClaw 命令`,
380
+ ``,
381
+ `示例:群里发 @${this.config.name} //status,可把 /status 直接交给 OpenClaw。`,
352
382
  ].join("\n");
353
383
  await this.replyMessage(messageId, helpText);
354
384
  markCommandSynced();
@@ -390,14 +420,23 @@ export class FeishuBot {
390
420
  markCommandSynced();
391
421
  return;
392
422
  }
393
- const chatInfo = this.store.getChatInfo(chatId);
394
- const isOn = chatInfo?.freeDiscussion || false;
395
- this.store.setFreeDiscussion(chatId, !isOn);
396
- if (isOn) {
397
- await this.replyMessage(messageId, "🔒 Free Discussion 已关闭\n群聊中需要 @ 指定 Bot 才会回复");
423
+ const parts = cleanText.trim().split(/\s+/);
424
+ const arg = (parts[1] || "").toLowerCase();
425
+ const isOn = this.store.getBotFreeDiscussion(this.config.name, chatId);
426
+ const next = arg === "on" || arg === "true" || arg === "1"
427
+ ? true
428
+ : arg === "off" || arg === "false" || arg === "0"
429
+ ? false
430
+ : arg === "status"
431
+ ? isOn
432
+ : !isOn;
433
+ if (arg !== "status")
434
+ this.store.setBotFreeDiscussion(this.config.name, chatId, next);
435
+ if (next) {
436
+ await this.replyMessage(messageId, `🔓 ${this.config.name} Free Discussion 已开启\n只影响当前 Bot 在当前群聊的自由发言(连续 Bot 回复超过 ${MAX_BOT_STREAK} 轮将暂停,等待人类发言)`);
398
437
  }
399
438
  else {
400
- await this.replyMessage(messageId, "🔓 Free Discussion 已开启\n所有 Bot 可以自由参与讨论(连续 Bot 回复超过 " + MAX_BOT_STREAK + " 轮将暂停,等待人类发言)");
439
+ await this.replyMessage(messageId, `🔒 ${this.config.name} Free Discussion 已关闭\n只影响当前 Bot;群聊中需要 @ 指定 Bot 才会回复`);
401
440
  }
402
441
  markCommandSynced();
403
442
  return;
@@ -576,6 +615,16 @@ export class FeishuBot {
576
615
  await new Promise((r) => setTimeout(r, 200));
577
616
  }
578
617
  }
618
+ shouldHandleBridgeCommand(chatType, message, isBot, rawText) {
619
+ if (chatType === "p2p")
620
+ return !isBot;
621
+ if (isBot)
622
+ return false;
623
+ const mentions = message.mentions || [];
624
+ if (this.isAllMention(rawText, mentions))
625
+ return true;
626
+ return this.isMentioned(mentions);
627
+ }
579
628
  shouldRespond(chatType, message, isBot, chatId, rawText) {
580
629
  if (chatType === "p2p")
581
630
  return !isBot;
@@ -585,52 +634,48 @@ export class FeishuBot {
585
634
  return this.isMentioned(mentions);
586
635
  }
587
636
  // @all in text: all bots respond
588
- if (rawText && (rawText.includes("@_all") || rawText.includes("@all")))
637
+ if (this.isAllMention(rawText, mentions))
589
638
  return true;
590
639
  // Check if this bot is explicitly mentioned
591
640
  if (this.isMentioned(mentions))
592
641
  return true;
593
642
  // Check if any other bot is mentioned (not us) — don't respond
594
- const anyBotMentioned = mentions.some((m) => {
595
- for (const bot of FeishuBot.allBots.values()) {
596
- if (m.id?.app_id === bot.config.appId)
597
- return true;
598
- if (bot.botOpenId && m.id?.open_id === bot.botOpenId)
599
- return true;
600
- }
601
- return false;
602
- });
643
+ const anyBotMentioned = mentions.some((m) => this.mentionedBotName(m) !== null);
603
644
  if (anyBotMentioned && !this.isMentioned(mentions))
604
645
  return false;
605
646
  // No bot mentioned: check free discussion mode
606
647
  if (chatId) {
607
- const chatInfo = this.store.getChatInfo(chatId);
608
- if (chatInfo?.freeDiscussion)
648
+ if (this.store.getBotFreeDiscussion(this.config.name, chatId))
609
649
  return true;
610
650
  }
611
651
  // Default: don't respond without @
612
652
  return false;
613
653
  }
614
654
  isMentioned(mentions) {
615
- return mentions.some((m) => {
616
- // @ this specific bot
617
- if (m.id?.app_id === this.config.appId)
618
- return true;
619
- if (this.botOpenId && m.id?.open_id === this.botOpenId)
620
- return true;
621
- // Feishu may provide only a display name in some mention payloads.
622
- // Use this only as a fallback; open_id/app_id checks above remain primary.
623
- if (typeof m.name === "string") {
624
- const normalizedName = m.name.toLowerCase();
625
- const normalizedConfigName = this.config.name.toLowerCase();
626
- if (normalizedName === normalizedConfigName || normalizedName.includes(`(${normalizedConfigName})`) || normalizedName.includes(`(${normalizedConfigName})`))
627
- return true;
628
- }
629
- // @all / @ 所有人 is handled by raw text gating, not by direct-bot mention matching.
630
- if (m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人")
631
- return false;
632
- return false;
633
- });
655
+ return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
656
+ }
657
+ isAllMention(rawText, mentions = []) {
658
+ if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
659
+ return true;
660
+ return mentions.some((m) => m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人");
661
+ }
662
+ mentionedBotName(mention) {
663
+ if (mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人")
664
+ return null;
665
+ const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
666
+ for (const bot of candidates) {
667
+ if (mention.id?.app_id === bot.config.appId)
668
+ return bot.config.name;
669
+ if (bot.botOpenId && mention.id?.open_id === bot.botOpenId)
670
+ return bot.config.name;
671
+ if (typeof mention.name === "string") {
672
+ const n = mention.name.toLowerCase();
673
+ const botName = bot.config.name.toLowerCase();
674
+ if (n === botName || n.includes(`(${botName})`) || n.includes(`(${botName})`))
675
+ return bot.config.name;
676
+ }
677
+ }
678
+ return null;
634
679
  }
635
680
  resolveBotName(sender) {
636
681
  const openId = sender?.sender_id?.open_id;
@@ -647,6 +692,18 @@ export class FeishuBot {
647
692
  cleanMentions(text) {
648
693
  return text.replace(/@_user_\d+/g, "").trim();
649
694
  }
695
+ stripLeadingCommandMentions(text) {
696
+ let s = text.trim();
697
+ let prev = "";
698
+ while (s !== prev) {
699
+ prev = s;
700
+ s = s
701
+ .replace(/^(@_all|@all|@所有人|所有人)\s*/i, "")
702
+ .replace(/^@\S+\s*/u, "")
703
+ .trimStart();
704
+ }
705
+ return s;
706
+ }
650
707
  async replyMessage(messageId, text) {
651
708
  // Use interactive card for markdown rendering
652
709
  const card = {
@@ -699,16 +756,23 @@ export class FeishuBot {
699
756
  },
700
757
  });
701
758
  }
702
- catch {
759
+ catch (err) {
760
+ console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
703
761
  // Fallback to plain text
704
- await this.client.im.message.create({
705
- params: { receive_id_type: "chat_id" },
706
- data: {
707
- receive_id: chatId,
708
- content: JSON.stringify({ text }),
709
- msg_type: "text",
710
- },
711
- });
762
+ try {
763
+ await this.client.im.message.create({
764
+ params: { receive_id_type: "chat_id" },
765
+ data: {
766
+ receive_id: chatId,
767
+ content: JSON.stringify({ text }),
768
+ msg_type: "text",
769
+ },
770
+ });
771
+ }
772
+ catch (fallbackErr) {
773
+ console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
774
+ throw fallbackErr;
775
+ }
712
776
  }
713
777
  }
714
778
  /**
@@ -785,7 +849,7 @@ export class FeishuBot {
785
849
  const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
786
850
  const status = session?.status || "unknown";
787
851
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
788
- const freeStatus = chatInfo?.freeDiscussion ? "🔓 开启" : "🔒 关闭";
852
+ const freeStatus = this.store.getBotFreeDiscussion(this.config.name, chatId) ? "🔓 开启" : "🔒 关闭";
789
853
  const statusText = [
790
854
  `📊 ${this.config.name} Bot Status`,
791
855
  `━━━━━━━━━━━━━━━━━━`,
@@ -58,6 +58,8 @@ export declare class MessageStore {
58
58
  setVerbose(chatId: string, verbose: boolean): void;
59
59
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
60
60
  getBotVerbose(botName: string, chatId: string): boolean;
61
+ setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
62
+ getBotFreeDiscussion(botName: string, chatId: string): boolean;
61
63
  getChatInfo(chatId: string): ChatInfo | null;
62
64
  getAllChatInfo(): ChatInfo[];
63
65
  /**
@@ -70,11 +70,12 @@ export class MessageStore {
70
70
  );
71
71
 
72
72
  -- Per-bot, per-chat settings. A group can contain multiple bots, so settings
73
- -- like verbose must not be shared globally at chat level.
73
+ -- like verbose/free discussion must not be shared globally at chat level.
74
74
  CREATE TABLE IF NOT EXISTS bot_chat_settings (
75
75
  bot_name TEXT NOT NULL,
76
76
  chat_id TEXT NOT NULL,
77
77
  verbose INTEGER NOT NULL DEFAULT 0,
78
+ free_discussion INTEGER NOT NULL DEFAULT 0,
78
79
  updated_at INTEGER NOT NULL DEFAULT 0,
79
80
  PRIMARY KEY (bot_name, chat_id)
80
81
  );
@@ -100,6 +101,13 @@ export class MessageStore {
100
101
  catch {
101
102
  // Column already exists
102
103
  }
104
+ // Migration: make free discussion per-bot per-chat.
105
+ try {
106
+ this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN free_discussion INTEGER NOT NULL DEFAULT 0`);
107
+ }
108
+ catch {
109
+ // Column already exists
110
+ }
103
111
  }
104
112
  /**
105
113
  * Insert a message. Returns the auto-increment id, or -1 if duplicate.
@@ -270,6 +278,22 @@ export class MessageStore {
270
278
  `).get(botName, chatId);
271
279
  return !!row?.verbose;
272
280
  }
281
+ setBotFreeDiscussion(botName, chatId, on) {
282
+ this.db.prepare(`
283
+ INSERT INTO bot_chat_settings (bot_name, chat_id, free_discussion, updated_at)
284
+ VALUES (?, ?, ?, ?)
285
+ ON CONFLICT (bot_name, chat_id) DO UPDATE SET
286
+ free_discussion = excluded.free_discussion,
287
+ updated_at = excluded.updated_at
288
+ `).run(botName, chatId, on ? 1 : 0, Date.now());
289
+ }
290
+ getBotFreeDiscussion(botName, chatId) {
291
+ const row = this.db.prepare(`
292
+ SELECT free_discussion FROM bot_chat_settings
293
+ WHERE bot_name = ? AND chat_id = ?
294
+ `).get(botName, chatId);
295
+ return !!row?.free_discussion;
296
+ }
273
297
  getChatInfo(chatId) {
274
298
  const row = this.db.prepare(`SELECT * FROM chat_info WHERE chat_id = ?`).get(chatId);
275
299
  if (!row)
@@ -139,15 +139,31 @@ export class OpenClawClient {
139
139
  const msg = frame.payload.message || frame.payload;
140
140
  const role = msg.role;
141
141
  const content = msg.content;
142
- // Proactive assistant text messages (suppress during active chatSend)
143
- if (role === "assistant" && typeof content === "string") {
142
+ // Proactive assistant text messages (suppress during active chatSend).
143
+ // Cron/session-targeted runs often emit structured content arrays rather
144
+ // than a plain string. Extract only visible text parts and ignore thinking
145
+ // / tool blocks so the bridge can deliver final cron results via the bot.
146
+ let proactiveText = "";
147
+ if (role === "assistant") {
148
+ if (typeof content === "string") {
149
+ proactiveText = content;
150
+ }
151
+ else if (Array.isArray(content)) {
152
+ proactiveText = content
153
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
154
+ .map((part) => part.text)
155
+ .join("\n")
156
+ .trim();
157
+ }
158
+ }
159
+ if (proactiveText) {
144
160
  if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
145
161
  console.log(`[OpenClaw] Suppressing proactive msg for ${shortKey} (active chatSend)`);
146
162
  }
147
163
  else {
148
164
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
149
165
  if (cb)
150
- cb(content);
166
+ cb(proactiveText);
151
167
  }
152
168
  }
153
169
  // Tool calls in assistant messages — skip, using agent item events instead
@@ -232,6 +248,7 @@ export class OpenClawClient {
232
248
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
233
249
  let chatFinalTimer = null;
234
250
  let lifecycleEndTimer = null;
251
+ let replayInvalidTimer = null;
235
252
  const collectStartedAt = Date.now();
236
253
  let lifecycleStartedLogged = false;
237
254
  const timer = setTimeout(() => {
@@ -240,6 +257,8 @@ export class OpenClawClient {
240
257
  clearTimeout(chatFinalTimer);
241
258
  if (lifecycleEndTimer)
242
259
  clearTimeout(lifecycleEndTimer);
260
+ if (replayInvalidTimer)
261
+ clearTimeout(replayInvalidTimer);
243
262
  console.warn(`[OpenClaw] collectReply timeout for runId=${runId} sessionKey=${sessionKey}`);
244
263
  this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
245
264
  console.warn(`[OpenClaw] abort after collectReply timeout failed:`, err.message);
@@ -253,6 +272,8 @@ export class OpenClawClient {
253
272
  clearTimeout(chatFinalTimer);
254
273
  if (lifecycleEndTimer)
255
274
  clearTimeout(lifecycleEndTimer);
275
+ if (replayInvalidTimer)
276
+ clearTimeout(replayInvalidTimer);
256
277
  resolve(finalText);
257
278
  };
258
279
  const poller = setInterval(() => {
@@ -282,6 +303,12 @@ export class OpenClawClient {
282
303
  continue;
283
304
  }
284
305
  bucket.splice(i, 1);
306
+ // If more events arrive after a replay-invalid lifecycle end, that lifecycle
307
+ // was not terminal for the user-visible run. Keep waiting for the real final.
308
+ if (replayInvalidTimer) {
309
+ clearTimeout(replayInvalidTimer);
310
+ replayInvalidTimer = null;
311
+ }
285
312
  if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
286
313
  lifecycleStartedLogged = true;
287
314
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
@@ -310,20 +337,33 @@ export class OpenClawClient {
310
337
  const finalText = chatFinalText || text;
311
338
  const finishFromLifecycle = () => {
312
339
  const latestFinalText = chatFinalText || text;
340
+ if (!chatFinalText && latestFinalText.trim() === "N") {
341
+ // Some providers stream the first character of NO_REPLY ("N") but
342
+ // never deliver a final chat message in time. Never surface a lone
343
+ // "N" to the user; treat it as a suppressed reply.
344
+ finish("NO_REPLY");
345
+ return;
346
+ }
313
347
  if (!latestFinalText && ev.data?.livenessState !== "working") {
314
348
  const state = ev.data?.livenessState || "unknown";
315
349
  const reason = ev.data?.stopReason || "";
316
350
  const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
317
- finish(`⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`);
351
+ const failureText = `⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`;
352
+ if (ev.data?.replayInvalid) {
353
+ console.warn(`[OpenClaw] replayInvalid lifecycle observed for runId=${evRunId || runId}; waiting for subsequent events before surfacing failure`);
354
+ replayInvalidTimer = setTimeout(() => finish(failureText), 120000);
355
+ return;
356
+ }
357
+ finish(failureText);
318
358
  }
319
359
  else {
320
360
  finish(latestFinalText);
321
361
  }
322
362
  };
323
363
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
324
- // final reply. Wait briefly for chatFinal before resolving.
364
+ // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
325
365
  if (!chatFinalText && text.length <= 1) {
326
- lifecycleEndTimer = setTimeout(finishFromLifecycle, 800);
366
+ lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
327
367
  }
328
368
  else {
329
369
  finishFromLifecycle();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {