openclaw-lark-multi-agent 1.4.10 → 1.4.12

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
@@ -306,14 +306,22 @@ By default, a bot responds when:
306
306
 
307
307
  - it is directly mentioned;
308
308
  - `@all` / `@_all` appears in the message;
309
- - this bot's Free mode is enabled and the message is a plain human message with no targeted mentions.
309
+ - a Chairman is set for this group, and this bot's Free mode is enabled for a plain human message with no mentions;
310
+ - a Chairman is set, no bot is Free, and this bot is the Chairman (even if muted).
310
311
 
311
312
  Free mode is intentionally per-bot and conservative:
312
313
 
313
- - Free mode lets a bot reply to ordinary human messages without being mentioned.
314
+ - Free mode lets a bot reply to ordinary unmentioned human messages **only while this group has a Chairman**.
314
315
  - If a human message mentions another bot, only that bot may respond. Other Free-mode bots stay silent.
315
316
  - If a human message mentions a regular person, Free-mode bots stay silent.
316
- - `@all` remains a broadcast trigger and may activate every eligible bot.
317
+ - `@all` remains a broadcast trigger and may activate every eligible bot. If the message also targets somebody else but not this bot, target exclusivity wins.
318
+ - These rules apply equally to one-bot and multi-bot groups, including local commands such as `/help` and `/free`: no coordinator or single-bot fallback may answer a mention addressed elsewhere.
319
+ - Mention identity requires a matching configured `app_id` or an already probed bot `open_id`. Known conflicting app/open IDs veto a match; neither wins by order. An unprobed open ID cannot establish identity (a matching app ID still can). Display names, including exact names and parenthesized suffixes, never establish identity. Message metadata never updates probed identities.
320
+ - Structured rich-post `at` nodes can supply an already known open ID when mention metadata is absent; they cannot override conflicting metadata. Missing/unresolved mentions or standalone `@name`/platform placeholders fail closed. Embedded `@` in email addresses or ordinary text is not a command or identity signal.
321
+
322
+ **Clearing Chairman is not mute.** `/chairman off` (also `clear` / `none`) atomically clears the canonical group Chairman and persists Discuss off, stops not-yet-started discussion turns, and blocks **new unmentioned group model triggers**, including Free bots and stale Discuss-on state after reopening the database. It does not abort an executing agent, change an existing queue task's identity, or suppress its eventual result. Explicit verified self/multi-target mentions, standalone `@all`, and private chats retain their normal behavior. Free/mute settings are not reset.
323
+
324
+ Local management commands are evaluated before the ordinary-chat gate. Use `/chairman <exact configured bot name>` without mentions, or a verified `/chairman @Bot`, to restore the group; Free/Chairman fallback then works again. `/discuss on` still requires a Chairman and must be enabled again after off. Bare group help/settings retain their existing single-coordinator routing; bare `/chairman` shows usage, not status (use `/status`).
317
325
 
318
326
  Mention-only routing is supported. If a user first sends content and then sends only a bot mention, for example:
319
327
 
@@ -344,7 +352,7 @@ Persistent constraints (such as "do not call Feishu send tools directly" and the
344
352
 
345
353
  `/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
346
354
 
347
- - `/free` controls whether a single bot may answer plain human messages.
355
+ - `/free` controls whether a single bot may answer plain human messages in a group with a Chairman.
348
356
  - `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all non-muted bots plus the chairman in barrier-style rounds. Free mode is ignored inside discussion: every bot that is not muted participates regardless of its Free setting.
349
357
  - Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
350
358
  - Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
@@ -373,11 +381,11 @@ Each group can have exactly one chairman, set with `/chairman @Bot`. Setting a n
373
381
 
374
382
  ```text
375
383
  /chairman @Bot set the chairman
376
- /chairman show the current chairman
384
+ /chairman show usage (use /status for current chairman)
377
385
  /chairman off clear the chairman
378
386
  ```
379
387
 
380
- `/chairman` is a group-level command handled by one coordinator bot, so it produces a single reply.
388
+ `/chairman` is a group-level command: the verified target handles targeted setup, and the coordinator handles untargeted management. Multiple-target errors belong to the first verified target, never an unmentioned coordinator.
381
389
 
382
390
  ### `/locale`
383
391
 
@@ -601,10 +609,20 @@ State and context usage come from one bounded Gateway status lookup. K values
601
609
  are rounded integers (1000 tokens per K); unavailable/stale values are not shown
602
610
  as zero. The footer is a send-time snapshot, not a live status widget.
603
611
 
604
- LMA's foreground result wait and live-card refresh budget are now 10 minutes.
605
- When the session is still running, LMA sends a normal wait notice instead of
606
- claiming execution failed. The old card stops periodic/progress edits; background
612
+ LMA pauses foreground waiting after **10 consecutive minutes without effective
613
+ task activity**, not after ten minutes of total runtime. New owned assistant
614
+ text or distinct tool start/completion resets the collector's idle clock;
615
+ card elapsed ticks, status polling, usage-only and duplicate events do not.
616
+ There is no independent card lifetime, and time before chat.send admission /
617
+ collector start is not charged as task silence. A write completion at 9:35 means
618
+ no silence freeze before 19:35, not a freeze at 10:00.
619
+ LMA sends a normal wait notice rather than claiming execution failed, displaying
620
+ the actual status even if unavailable. Confirmed chat-final `yielded: true`
621
+ still pauses immediately. Once frozen, the old card does not auto-resume after
622
+ later tool activity (same published policy for silence and yield); background
607
623
  result handling and the original queue remain, with no automatic abort/replay.
608
624
  Real results are sent separately; necessary terminal card cleanup is retained.
609
625
  This does not shorten the Gateway's execution limit. Unverified steer V2 and
610
626
  question adapters are not included in this release.
627
+
628
+ Timing/API parameters and limitations: [foreground idle semantics](docs/foreground-idle-2026-09-11.md).
package/README.zh-CN.md CHANGED
@@ -236,7 +236,7 @@ openclaw-lark-multi-agent install-windows-service
236
236
  - `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
237
237
  - `/mode` — 查看当前 bot 在当前聊天里的模式
238
238
  - `/discuss on|off|status|stop|rounds N` — 控制群级多 bot 讨论模式(需先设置 Chairman;默认 10 轮)
239
- - `/chairman [@Bot|off]` — 设置/查看/清除本群唯一 Chairman
239
+ - `/chairman [@Bot|配置名|off]` — 设置/切换/清除本群唯一 Chairman
240
240
  - `/locale [zh|en]` — 设置/查看当前群语言
241
241
 
242
242
  如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
@@ -284,14 +284,22 @@ openclaw-lark-multi-agent install-windows-service
284
284
 
285
285
  - 被直接 @;
286
286
  - 消息里出现 `@all` / `@_all`;
287
- - 当前 bot 开启了 Free 模式,并且这是一条没有定向 @ 的普通人类消息。
287
+ - 本群已设置 Chairman、当前 bot 开启了 Free 模式,且这是一条无 @ 的普通人类消息;
288
+ - 本群已设置 Chairman、没有 Free bot,由 Chairman 兜底(即使 Chairman 是 mute)。
288
289
 
289
290
  Free 模式是 per-bot 且保守的:
290
291
 
291
- - Free 模式允许 bot 在没有被 @ 时回复普通人类消息。
292
+ - Free 模式**仅在本群已设置 Chairman 时**允许 bot 回复无 @ 的普通人类消息。
292
293
  - 如果人类消息 @ 了另一个 bot,只有被 @ 的 bot 可以回复,其他 Free 模式 bot 保持静默。
293
294
  - 如果人类消息 @ 了普通人,Free 模式 bot 保持静默。
294
- - `@all` 仍然是广播触发,可激活所有符合条件的 bot。
295
+ - `@all` 仍然是广播触发,可激活所有符合条件的 bot;但同时定向 @ 其他人/bot 且没有 @ 当前 bot 时,定向排他优先。
296
+ - 单 bot 和多 bot 群、Free/Chairman/Discuss、`/help`、`/free` 等本地命令统一遵循定向排他规则;coordinator 或单 bot 不能兜底抢答。
297
+ - 点名身份仅由匹配的配置 `app_id` 或启动探测已确认的 bot `open_id` 确定。已知 app/open ID 冲突时拒绝匹配,不按顺序让其中一个优先;尚未探测的 open ID 不能独立确认身份,但匹配的 app ID 仍可确认。名字、同名人/外部机器人、括号后缀不构成身份依据,消息元数据不会覆盖已探测身份。
298
+ - 富文本结构化 `at` 节点只在缺少 mention 元数据时可按已知 open ID 确认目标,不能覆盖冲突元数据。缺元数据的 `@name` / 平台占位符及无法确认的目标保持安静;邮箱或普通文字内嵌的 `@` 不当作指令或身份信号。
299
+
300
+ **Chairman off 不是 mute,但禁止无 @ 的普通新消息触发模型。** `/chairman off`(也支持 `clear` / `none`)原子持久化清空群 Chairman 并关闭 Discuss,停止尚未开始的讨论轮次;即使 bot 原来 Free 或数据库遗留 Discuss on,无 Chairman 时普通新消息也不会触发。不会 abort 已执行 agent、改变旧队列任务身份或阻止其最终结果回传;明确 @ 自己/多目标含自己、单独 `@all`、私聊仍按原规则,Free/mute 设置不清除。
301
+
302
+ 本地管理命令先于普通聊天门禁处理。可无 @ 发送 `/chairman 精确配置名`,或通过可靠点名 `/chairman @Bot` 恢复;之后 Free/Chairman 兜底恢复原行为。Discuss 需重新 `/discuss on` 且必须已有 Chairman。无目标管理命令保留原 coordinator 规则;裸 `/chairman` 返回用法,查看状态用 `/status`。
295
303
 
296
304
  支持“纯 @ 触发”。例如用户先发:
297
305
 
@@ -323,8 +331,8 @@ catch-up 上下文是 bot 随当前消息一起拿到的群里未看到的发言
323
331
 
324
332
  `/discuss` 是显式的群级多智能体讨论调度器,和 Free 模式分工不同:
325
333
 
326
- - `/free` 控制单个 bot 是否可以响应普通人类消息。
327
- - `/discuss on` 需先设置 Chairman(`/chairman @Bot`)。它让一个 coordinator 接管普通人类消息,并按 barrier-style round 调度所有 Free 模式 bot 加上 Chairman。
334
+ - `/free` 控制单个 bot 是否可以在已设 Chairman 的群响应普通人类消息。
335
+ - `/discuss on` 需先设置 Chairman(`/chairman @Bot`)。它让一个 coordinator 接管普通人类消息,并按 barrier-style round 调度所有非 mute bot 加上 Chairman,忽略 Free 开关。
328
336
  - 定向 @ 仍然走普通路由,所以 discuss 开启时 `@GPT hello` 仍会只触发 GPT。
329
337
  - 每个参与 bot 在同一轮拿到相同 prompt,本轮内看不到其他 bot 的回复,下一轮才会看到上一轮结果。
330
338
  - Chairman 每轮最后发言:先给出自己的观点,质疑薄弱点,调停分歧,并决定继续还是总结。
@@ -352,11 +360,11 @@ catch-up 上下文是 bot 随当前消息一起拿到的群里未看到的发言
352
360
 
353
361
  ```text
354
362
  /chairman @Bot 设置 Chairman
355
- /chairman 查看当前 Chairman
363
+ /chairman 显示设置用法;查看状态请用 /status
356
364
  /chairman off 清除 Chairman
357
365
  ```
358
366
 
359
- `/chairman` 是群级命令,由一个 coordinator bot 统一处理,只产生一条回复。
367
+ `/chairman` 是群级命令:定向设置由目标 bot 处理,无目标管理由 coordinator 处理;多目标错误由第一个可靠目标回复,未被点名的 coordinator 不抢答。
360
368
 
361
369
  ### `/locale` 语言
362
370
 
@@ -509,8 +517,16 @@ MIT
509
517
  1000 token 四舍五入为整数。缺失或明确过期的数据不冒充 0。尾部反映发送前
510
518
  查询的状态,不会持续自动刷新历史回复。
511
519
 
512
- 前台结果等待与过程卡刷新预算调整为 10 分钟。会话仍在 running 时发送普通
513
- 等待提示,不把它当作整个任务执行失败;旧卡不再周期刷新或随工具进度编辑。
520
+ 前台等待改为**连续 10 分钟没有有效任务活动才暂停**,不是任务总时长上限。
521
+ 已归属本次任务的新 assistant 文本、不同工具调用的开始/结束等会重置 collector
522
+ 的静默计时;卡片 elapsed ticker、状态轮询、usage-only 与重复事件不会续时。
523
+ 卡片没有独立总时长计时器;chat.send 接受及 collector 启动前的排队/内部耗时不算
524
+ 任务静默。9:35 的 write 完成后,最早 19:35 静默到期,不能在 10:00 冻结。
525
+ 到期发送普通等待提示,显示实际状态(不可用也不冒充 running),不判任务失败。
526
+ 真正的 chat-final `yielded: true` 仍立即提示并冻结,不等 10 分钟。
527
+ 普通静默暂停与 yield 都沿用已发布策略:后续工具活动不会自动解冻旧卡。
514
528
  后台仍监听原任务,保留原队列,不因此停止或自动重发请求。真实结果另行回复,
515
529
  保留必要的终态收尾。本改动不缩短 Gateway 的执行时限,也不包含未验收的
516
530
  steer V2 与问答适配器。
531
+
532
+ 计时、API 参数及限制详见 [前台静默计时说明](docs/foreground-idle-2026-09-11.md)。
@@ -124,10 +124,17 @@ export class DiscussionManager {
124
124
  parts.push(`${errorNames.join("、")} ${t.error}`);
125
125
  await sendSystemMessage(t.discussionRoundNotice(current.currentRound, current.maxRounds, parts.join(current.locale === "zh" ? ";" : "; "))).catch(() => { });
126
126
  }
127
+ // stop/preemption may occur while a system notice is being delivered.
128
+ if (this.sessions.get(session.chatId)?.id !== sessionId || current.status !== "running")
129
+ return;
127
130
  const mustFinish = allNoReply || current.currentRound >= current.maxRounds;
128
131
  if (chairman) {
129
132
  const chairmanPrompt = this.buildChairmanPrompt(current, replies, mustFinish);
130
133
  const chairResult = await chairman.runDiscussionTurn(current.chatId, chairmanPrompt, { round: current.currentRound, maxRounds: current.maxRounds });
134
+ // An old in-flight Chairman may finish after stop or replacement. Its
135
+ // delivery belongs to that turn, but it must not mutate the new session.
136
+ if (this.sessions.get(session.chatId)?.id !== sessionId || current.status !== "running")
137
+ return;
131
138
  const chairText = (chairResult.text || "").trim();
132
139
  replies[chairman.name] = chairText;
133
140
  const latest = current.completedRounds[current.completedRounds.length - 1];
@@ -128,7 +128,6 @@ export declare class FeishuBot {
128
128
  private isAllMention;
129
129
  private isAllMentionItem;
130
130
  private mentionedBotName;
131
- private escapeRegExp;
132
131
  private resolveBotName;
133
132
  private resolveHumanName;
134
133
  private cleanMentions;
@@ -206,16 +205,6 @@ export declare class FeishuBot {
206
205
  private dispatchPendingDeliveries;
207
206
  private hasFreeModeBot;
208
207
  private mentionedBotNames;
209
- private mentionedBotNamesFromChairmanText;
210
- /**
211
- * Resolve the target bot name(s) for a /chairman command using every signal
212
- * available, in priority order:
213
- * 1. Feishu mention metadata (app_id / open_id / name).
214
- * 2. Text fallback: "@Bot" or "Bot" written in the message body.
215
- *
216
- * This keeps /chairman working even when a Feishu client or bridge omits
217
- * mention metadata. Returns a de-duplicated, order-preserving list.
218
- */
219
208
  private resolveChairmanTargets;
220
209
  private isDiscussionCoordinator;
221
210
  private static markBotSeenInChat;
@@ -414,15 +414,8 @@ export class FeishuBot {
414
414
  const messageType = message.message_type;
415
415
  const messageId = message.message_id;
416
416
  const isBot = sender?.sender_type === "app";
417
- // Extract bot open_id from mentions
418
- if (message.mentions) {
419
- for (const m of message.mentions) {
420
- const bot = FeishuBot.allBots.get(m.id?.app_id || "");
421
- if (bot && m.id?.open_id) {
422
- bot.botOpenId = m.id.open_id;
423
- }
424
- }
425
- }
417
+ // Identity comes from the startup probe, never from message mentions:
418
+ // a conflicting app_id/open_id pair must not overwrite a known identity.
426
419
  if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post" && messageType !== "share_doc")
427
420
  return;
428
421
  // --- Dedup: atomically claim this message for this bot before any await.
@@ -512,7 +505,7 @@ export class FeishuBot {
512
505
  cleanText = await this.hydrateFeishuDocsInMessage(cleanText, content, messageId);
513
506
  if (!cleanText.trim())
514
507
  return;
515
- const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
508
+ const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "", content);
516
509
  // Commands may be prefixed by @all / @bot in group chats. Strip those
517
510
  // leading routing mentions before deciding whether this is a bridge command
518
511
  // or an escaped OpenClaw command.
@@ -551,6 +544,10 @@ export class FeishuBot {
551
544
  });
552
545
  if (insertedId < 0)
553
546
  insertedId = this.store.getMessageId(messageId) || -1;
547
+ // Target exclusivity precedes ALL group command/coordinator, broadcast,
548
+ // single-bot, mute, Free and Discuss paths. Unknown targets are not self.
549
+ if (chatType !== "p2p" && routing.hasTargetedMention && !routing.isCurrentBotMentioned)
550
+ return;
554
551
  // --- Commands: in p2p always respond; in group, check shouldRespond first ---
555
552
  // Single slash commands are handled by the bridge. Double slash commands were
556
553
  // already unescaped above and should pass through to OpenClaw instead.
@@ -589,35 +586,28 @@ export class FeishuBot {
589
586
  }
590
587
  }
591
588
  else if (isChairmanCommand) {
592
- // /chairman @Bot is owned by the mentioned bot itself. This avoids
593
- // requiring a coordinator to parse another bot's mention metadata,
594
- // which can vary across Feishu clients/bridges. Untargeted/status,
595
- // @all, and clear/off remain coordinator-owned group commands.
596
- //
597
- // Target resolution uses both Feishu mention metadata AND a text
598
- // fallback (parsing "@Bot" / "Bot" from the raw message), so a
599
- // /chairman that arrives without mention metadata still routes to
600
- // the intended bot instead of silently failing.
589
+ // Verified mentions belong to the targeted bot. Bare configured
590
+ // names remain local management arguments, not mention identity.
601
591
  const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
602
592
  const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
603
593
  const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
604
- const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
594
+ const targets = routing.hasTargetedMention ? routing.targetedBotNames : this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
605
595
  // A pure status query: no clear keyword, no mention metadata, and no
606
596
  // textual argument at all. Anything else is a set/route attempt.
607
597
  const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
608
598
  if (isClearArg || isStatusArg) {
609
- // Group-level status/clear: one coordinator handles it.
610
- if (!this.isDiscussionCoordinator())
599
+ // One eligible owner: first verified target, or bare coordinator.
600
+ if (routing.hasTargetedMention ? routing.targetedBotNames[0] !== this.config.name : !this.isDiscussionCoordinator())
611
601
  return;
612
602
  }
613
603
  else if (targets.length >= 1) {
614
604
  // Targets resolved: only the (single) targeted bot acts. If
615
- // multiple distinct targets resolved, the coordinator handles
616
- // the "only one chairman" error once.
605
+ // multiple distinct targets resolved, the first target handles
606
+ // the "only one chairman" error once (never an unmentioned bot).
617
607
  const uniqueTargets = Array.from(new Set(targets));
618
608
  const targetedSelf = uniqueTargets.includes(this.config.name);
619
609
  if (uniqueTargets.length > 1) {
620
- if (!this.isDiscussionCoordinator())
610
+ if (uniqueTargets[0] !== this.config.name)
621
611
  return;
622
612
  }
623
613
  else if (!targetedSelf) {
@@ -800,6 +790,7 @@ export class FeishuBot {
800
790
  if (next === "free") {
801
791
  await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式
802
792
  不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。
793
+ 需先设置本群 Chairman,Free 才会回答无 @ 的普通消息。
803
794
  如需多轮自动讨论,请使用群级命令 /discuss on。`);
804
795
  }
805
796
  else {
@@ -845,7 +836,7 @@ export class FeishuBot {
845
836
  return;
846
837
  }
847
838
  if (commandName === "/chairman") {
848
- await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
839
+ await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText, routing.hasTargetedMention ? routing.targetedBotNames : undefined);
849
840
  markCommandSynced();
850
841
  return;
851
842
  }
@@ -855,11 +846,16 @@ export class FeishuBot {
855
846
  return;
856
847
  }
857
848
  }
849
+ // Local management commands above can restore Chairman. Only NEW
850
+ // unmentioned human triggers are gated; existing queue/delivery owners stay.
851
+ if (chatType !== "p2p" && !isBot && !routing.hasTargetedMention && !routing.isAllMention
852
+ && !this.store.getChairmanBot(chatId))
853
+ return;
858
854
  // --- Discuss mode: group-level multi-bot round scheduler. It takes over
859
855
  // plain human messages so normal Free mode does not duplicate Round 1.
860
856
  // Targeted mentions must fall through to normal routing so @GPT still
861
857
  // works while discuss mode is enabled.
862
- if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
858
+ if (chatType !== "p2p" && !isBot && this.store.getChairmanBot(chatId) && this.store.getChatInfo(chatId)?.discuss) {
863
859
  // Discuss mode owns ordinary and @all human messages. Explicit @bot/@human
864
860
  // falls through to normal targeted routing.
865
861
  if (!routing.hasTargetedMention) {
@@ -1361,8 +1357,8 @@ export class FeishuBot {
1361
1357
  currentMessage: mergedContent,
1362
1358
  currentSenderName: lastHuman.senderName,
1363
1359
  deliver: false,
1364
- // Keep bridge UX responsive; long agent/tool loops should surface a clear failure
1365
- // instead of leaving reactions stuck forever.
1360
+ // Pause foreground only after consecutive effective-activity silence;
1361
+ // active long-running tool loops are not a failure.
1366
1362
  timeoutMs: FOREGROUND_WAIT_MS,
1367
1363
  includeContext: !isNativeCommandTrigger,
1368
1364
  includeBridgeAttachmentHint: !isNativeCommandTrigger,
@@ -1702,10 +1698,8 @@ export class FeishuBot {
1702
1698
  return !isBot;
1703
1699
  if (isBot)
1704
1700
  return false;
1705
- const mentions = message.mentions || [];
1706
- if (this.isAllMention(rawText, mentions))
1707
- return true;
1708
- return this.isMentioned(mentions);
1701
+ const routing = this.getRoutingIntent(chatType, message, rawText);
1702
+ return routing.hasTargetedMention ? routing.isCurrentBotMentioned : routing.isAllMention;
1709
1703
  }
1710
1704
  shouldRespond(chatType, _message, isBot, chatId, routing) {
1711
1705
  if (chatType === "p2p")
@@ -1713,17 +1707,16 @@ export class FeishuBot {
1713
1707
  // Bot messages: only respond if this bot is explicitly mentioned.
1714
1708
  if (isBot)
1715
1709
  return routing.isCurrentBotMentioned;
1716
- // @all is an explicit broadcast to all non-muted bots.
1717
- if (routing.isAllMention)
1718
- return true;
1719
1710
  // Explicit targeted mentions are exclusive. If this bot is not one of the
1720
1711
  // mentioned bots, free/chairman must not steal the turn.
1721
1712
  if (routing.hasTargetedMention)
1722
1713
  return routing.isCurrentBotMentioned;
1723
- // Human-only mentions are also exclusive; free/chairman should not jump in.
1724
- if (routing.hasHumanMention)
1725
- return false;
1714
+ // @all alone is an explicit broadcast, not an unmentioned message.
1715
+ if (routing.isAllMention)
1716
+ return true;
1726
1717
  if (chatId) {
1718
+ if (!this.store.getChairmanBot(chatId))
1719
+ return false;
1727
1720
  if (this.store.getBotMode(this.config.name, chatId) === "free")
1728
1721
  return true;
1729
1722
  // Chairman fallback: if nobody is in free mode, the unique chairman
@@ -1734,12 +1727,46 @@ export class FeishuBot {
1734
1727
  }
1735
1728
  return false;
1736
1729
  }
1737
- getRoutingIntent(chatType, message, rawText) {
1738
- const mentions = message.mentions || [];
1730
+ getRoutingIntent(chatType, message, rawText = "", content) {
1731
+ const metadata = message.mentions || [];
1732
+ const mentions = [...metadata];
1733
+ const mentionTexts = [rawText];
1734
+ if (message.message_type === "post") {
1735
+ // Only structured post nodes carry mention semantics; do not interpret
1736
+ // arbitrary JSON strings as identity or broaden attachment handling.
1737
+ const posts = [content, ...Object.values(content || {})];
1738
+ for (const post of posts) {
1739
+ if (!Array.isArray(post?.content))
1740
+ continue;
1741
+ if (typeof post.title === "string")
1742
+ mentionTexts.push(post.title);
1743
+ for (const paragraph of post.content) {
1744
+ if (!Array.isArray(paragraph))
1745
+ continue;
1746
+ for (const node of paragraph) {
1747
+ if (node?.tag === "text" && typeof node.text === "string")
1748
+ mentionTexts.push(node.text);
1749
+ if (node?.tag !== "at")
1750
+ continue;
1751
+ // Rich-post user_id is matched only to an already probed open_id.
1752
+ mentions.push({ id: { open_id: node.user_id }, name: node.user_name });
1753
+ }
1754
+ }
1755
+ }
1756
+ }
1739
1757
  const isAllMention = chatType !== "p2p" && this.isAllMention(rawText, mentions);
1740
- const targetedBotNames = this.mentionedBotNames(mentions);
1758
+ // Structured nodes fill missing metadata, not override conflicting metadata.
1759
+ const targetedBotNames = this.mentionedBotNames(metadata.length ? metadata : mentions);
1760
+ // Missing metadata must not turn a platform placeholder or standalone @name
1761
+ // into an unmentioned Free/Chairman trigger. This grants NO identity. Emails
1762
+ // and embedded @ characters remain ordinary text, not commands or mentions.
1763
+ const unresolvedTextMention = mentionTexts.some(text => {
1764
+ const withoutAllPlaceholders = text.replace(/@_user_\d+/g, key => metadata.some(m => m.key === key && this.isAllMentionItem(m)) ? " " : key);
1765
+ return /@_user_\d+/.test(withoutAllPlaceholders)
1766
+ || /(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(withoutAllPlaceholders);
1767
+ });
1741
1768
  const hasHumanMention = mentions.some((m) => !this.isAllMentionItem(m) && !this.mentionedBotName(m));
1742
- const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention;
1769
+ const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention || unresolvedTextMention;
1743
1770
  return {
1744
1771
  isAllMention,
1745
1772
  targetedBotNames,
@@ -1757,44 +1784,32 @@ export class FeishuBot {
1757
1784
  return mentions.some((m) => this.isAllMentionItem(m));
1758
1785
  }
1759
1786
  isAllMentionItem(mention) {
1760
- return mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人";
1787
+ const id = mention.id || {};
1788
+ // A display name cannot override a concrete non-broadcast identity.
1789
+ if (id.app_id || (id.open_id && id.open_id !== "all") || (id.user_id && id.user_id !== "all"))
1790
+ return false;
1791
+ return mention.key === "all" || mention.key === "@_all" || id.user_id === "all" || id.open_id === "all";
1761
1792
  }
1762
1793
  mentionedBotName(mention) {
1763
1794
  if (this.isAllMentionItem(mention))
1764
1795
  return null;
1765
- const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
1766
- for (const bot of candidates) {
1767
- if (mention.id?.app_id && mention.id.app_id === bot.config.appId)
1768
- return bot.config.name;
1769
- if (bot.botOpenId && mention.id?.open_id && mention.id.open_id === bot.botOpenId)
1770
- return bot.config.name;
1771
- }
1772
- // Name is only a fallback because Feishu should normally provide app_id/open_id.
1773
- // Keep it exact to avoid shared-prefix bots like 万万(GPT) / 万万(Claude)
1774
- // stealing each other's mentions.
1775
- if (typeof mention.name === "string") {
1776
- const raw = mention.name.trim().replace(/^@+/, "").replace(/\s+/g, "").toLowerCase();
1777
- for (const bot of candidates) {
1778
- const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
1779
- const exactNames = [
1780
- botName,
1781
- `万万(${botName})`,
1782
- `万万(${botName})`,
1783
- ];
1784
- if (exactNames.includes(raw))
1785
- return bot.config.name;
1786
- // Generic display-name fallback: many deployments name bots like
1787
- // "光子 (Claude)" or "万万(GPT)". Match only when the parenthesized
1788
- // suffix is exactly the configured bot name, avoiding loose substring
1789
- // matches that caused shared-prefix bots to steal each other's mentions.
1790
- if (new RegExp(`^[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw))
1791
- return bot.config.name;
1792
- }
1793
- }
1794
- return null;
1795
- }
1796
- escapeRegExp(value) {
1797
- return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
1796
+ const id = mention.id || {};
1797
+ const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
1798
+ const matches = candidates.filter((bot) => {
1799
+ // Known conflicting app/open credentials veto, regardless of order. An
1800
+ // unprobed open_id cannot establish identity, but a matching app_id can.
1801
+ if (id.app_id && id.app_id !== bot.config.appId)
1802
+ return false;
1803
+ if (id.open_id && bot.botOpenId && id.open_id !== bot.botOpenId)
1804
+ return false;
1805
+ if (id.open_id && candidates.some((other) => other !== bot && other.botOpenId === id.open_id))
1806
+ return false;
1807
+ return (id.app_id && id.app_id === bot.config.appId)
1808
+ || (id.open_id && bot.botOpenId && id.open_id === bot.botOpenId);
1809
+ });
1810
+ // Names (including exact/parenthesized suffixes) and unverified user/union
1811
+ // IDs never establish bot identity. Ambiguous matches fail closed.
1812
+ return matches.length === 1 ? matches[0].config.name : null;
1798
1813
  }
1799
1814
  resolveBotName(sender) {
1800
1815
  const openId = sender?.sender_id?.open_id;
@@ -2531,48 +2546,17 @@ export class FeishuBot {
2531
2546
  .filter((name) => Boolean(name));
2532
2547
  return Array.from(new Set(names));
2533
2548
  }
2534
- mentionedBotNamesFromChairmanText(...texts) {
2535
- const normalizedTexts = texts
2536
- .filter(Boolean)
2537
- .map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
2538
- .filter(Boolean)
2539
- .map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
2540
- if (normalizedTexts.length === 0)
2541
- return [];
2542
- const names = [];
2543
- const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
2544
- for (const raw of normalizedTexts) {
2545
- for (const bot of candidates) {
2546
- const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
2547
- const displayNames = [
2548
- botName,
2549
- `@${botName}`,
2550
- `万万(${botName})`,
2551
- `@万万(${botName})`,
2552
- `万万(${botName})`,
2553
- `@万万(${botName})`,
2554
- ];
2555
- if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
2556
- names.push(bot.config.name);
2557
- }
2558
- }
2559
- }
2560
- return Array.from(new Set(names));
2561
- }
2562
- /**
2563
- * Resolve the target bot name(s) for a /chairman command using every signal
2564
- * available, in priority order:
2565
- * 1. Feishu mention metadata (app_id / open_id / name).
2566
- * 2. Text fallback: "@Bot" or "Bot" written in the message body.
2567
- *
2568
- * This keeps /chairman working even when a Feishu client or bridge omits
2569
- * mention metadata. Returns a de-duplicated, order-preserving list.
2570
- */
2571
2549
  resolveChairmanTargets(mentions, text, rawText = "") {
2572
- const fromMeta = this.mentionedBotNames(mentions);
2573
- if (fromMeta.length > 0)
2574
- return fromMeta;
2575
- return this.mentionedBotNamesFromChairmanText(text, rawText);
2550
+ if (mentions.some((m) => !this.isAllMentionItem(m)))
2551
+ return this.mentionedBotNames(mentions);
2552
+ // Only a bare, exact configured name is a local management argument. Never
2553
+ // reinterpret an unresolved @name/placeholder as authority to target a bot.
2554
+ const argument = text.replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
2555
+ if (/(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(rawText) || argument.includes("@"))
2556
+ return [];
2557
+ return [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)]
2558
+ .filter((bot) => bot.config.name.toLowerCase() === argument)
2559
+ .map((bot) => bot.config.name);
2576
2560
  }
2577
2561
  isDiscussionCoordinator() {
2578
2562
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
@@ -3480,7 +3464,7 @@ export class FeishuBot {
3480
3464
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
3481
3465
  ].join("\n"));
3482
3466
  }
3483
- async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
3467
+ async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "", routedTargets) {
3484
3468
  if (chatType === "p2p") {
3485
3469
  await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
3486
3470
  return;
@@ -3490,10 +3474,12 @@ export class FeishuBot {
3490
3474
  if (["off", "clear", "none"].includes(action)) {
3491
3475
  const previous = this.store.getChairmanBot(chatId);
3492
3476
  this.store.clearChairmanBot(chatId);
3493
- await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
3477
+ discussionManager.stop(chatId); // Stop future turns; never abort in-flight agents.
3478
+ await this.replyMessage(messageId, (previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman")
3479
+ + "\nDiscuss 已关闭;无 @ 的普通新消息不再触发回复(含 Free)。这不是 mute,明确 @ 仍按原规则处理;已执行任务不被中止。");
3494
3480
  return;
3495
3481
  }
3496
- const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
3482
+ const resolvedBotNames = routedTargets ?? this.resolveChairmanTargets(mentions, text, rawText);
3497
3483
  if (resolvedBotNames.length === 0) {
3498
3484
  // /chairman is intentionally not a status query. Status lives in /status;
3499
3485
  // this command is only for switching or clearing the Chairman. Keeping the
@@ -50,8 +50,6 @@ export type LiveStatusOptions = {
50
50
  historySize?: number;
51
51
  /** Footer/auto-refresh cadence in ms. */
52
52
  tickMs?: number;
53
- /** Fixed card refresh lifetime; activity does not extend it. */
54
- refreshBudgetMs?: number;
55
53
  };
56
54
  export declare class LiveStatusController {
57
55
  private readonly callbacks;
@@ -63,7 +61,6 @@ export declare class LiveStatusController {
63
61
  private startedAt;
64
62
  private createTimer?;
65
63
  private tickTimer?;
66
- private refreshBudgetTimer?;
67
64
  private createPromise?;
68
65
  private finalized;
69
66
  private waitingForResult;
@@ -1,4 +1,3 @@
1
- import { FOREGROUND_WAIT_MS } from "./session-status.js";
2
1
  const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
3
2
  const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
4
3
  // How many recent activity lines to keep in the card content area.
@@ -23,7 +22,6 @@ export class LiveStatusController {
23
22
  startedAt = Date.now();
24
23
  createTimer;
25
24
  tickTimer;
26
- refreshBudgetTimer;
27
25
  createPromise;
28
26
  finalized = false;
29
27
  waitingForResult = false;
@@ -49,12 +47,9 @@ export class LiveStatusController {
49
47
  if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
50
48
  return;
51
49
  this.startedAt = Date.now();
52
- this.refreshBudgetTimer = setTimeout(() => {
53
- void this.showWaitingForResult(this.opts.locale === "en"
54
- ? "Foreground refresh budget reached; background results are still monitored."
55
- : "前台刷新时间已到,过程卡停止实时刷新;后台继续接收结果。").catch(err => this.callbacks.warn?.("live status freeze failed", err));
56
- }, this.opts.refreshBudgetMs ?? FOREGROUND_WAIT_MS);
57
- this.refreshBudgetTimer.unref?.();
50
+ // Presentation may start before chat.send admission. Only the collector's
51
+ // effective-activity idle decision (or confirmed yield) freezes this card;
52
+ // elapsed ticks are presentation, never evidence of task progress.
58
53
  if (initialDetail && initialDetail.trim()) {
59
54
  this.pushLine("lifecycle", initialDetail.trim());
60
55
  }
@@ -253,10 +248,6 @@ export class LiveStatusController {
253
248
  this.lines = this.lines.slice(this.lines.length - limit);
254
249
  }
255
250
  stopTimers() {
256
- if (this.refreshBudgetTimer) {
257
- clearTimeout(this.refreshBudgetTimer);
258
- this.refreshBudgetTimer = undefined;
259
- }
260
251
  if (this.createTimer) {
261
252
  clearTimeout(this.createTimer);
262
253
  this.createTimer = undefined;
@@ -891,7 +891,12 @@ export class MessageStore {
891
891
  `).run(chatId, botName, Date.now());
892
892
  }
893
893
  clearChairmanBot(chatId) {
894
- this.setChairmanBot(chatId, '');
894
+ // Persist both fields atomically so reopening cannot revive Discuss after off.
895
+ this.db.prepare(`
896
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, chairman_bot, discuss, updated_at)
897
+ VALUES (?, 'group', '', '', 0, ?)
898
+ ON CONFLICT (chat_id) DO UPDATE SET chairman_bot = '', discuss = 0, updated_at = excluded.updated_at
899
+ `).run(chatId, Date.now());
895
900
  }
896
901
  getChairmanBot(chatId) {
897
902
  const row = this.db.prepare(`SELECT chairman_bot FROM chat_info WHERE chat_id = ?`).get(chatId);
@@ -226,6 +226,7 @@ export declare class OpenClawClient {
226
226
  message: string;
227
227
  attachments?: ChatAttachment[];
228
228
  deliver?: boolean;
229
+ /** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
229
230
  timeoutMs?: number;
230
231
  emptyFinalAsNoReply?: boolean;
231
232
  /** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
@@ -256,6 +257,7 @@ export declare class OpenClawClient {
256
257
  currentMessage: string;
257
258
  currentSenderName: string;
258
259
  deliver?: boolean;
260
+ /** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
259
261
  timeoutMs?: number;
260
262
  emptyFinalAsNoReply?: boolean;
261
263
  /** Native escaped commands (//status -> /status) should not receive catch-up context. */
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
3
- import { randomUUID } from "crypto";
3
+ import { createHash, randomUUID } from "crypto";
4
4
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
5
5
  import { basename, extname, join, resolve, sep } from "path";
6
6
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -759,7 +759,6 @@ export class OpenClawClient {
759
759
  let foregroundPaused = false;
760
760
  let foregroundYielded = false;
761
761
  let turnYielded = false;
762
- let foregroundWaitTimer;
763
762
  const handleTimeoutResult = async (error) => {
764
763
  if (observationClosed)
765
764
  return;
@@ -771,8 +770,6 @@ export class OpenClawClient {
771
770
  // Foreground notice, background observation. Keep the original queue
772
771
  // owner/progress path so later input still steers into the same run.
773
772
  foregroundPaused = true;
774
- if (foregroundWaitTimer)
775
- clearTimeout(foregroundWaitTimer);
776
773
  if (!waitNoticeSent) {
777
774
  waitNoticeSent = true;
778
775
  try {
@@ -789,7 +786,7 @@ export class OpenClawClient {
789
786
  }
790
787
  reject(error);
791
788
  };
792
- const checkRunState = async () => {
789
+ const checkRunState = async (idleStillCurrent = () => true) => {
793
790
  if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
794
791
  return;
795
792
  checkingState = true;
@@ -815,8 +812,10 @@ export class OpenClawClient {
815
812
  else if (snapshot.status === "error" || snapshot.status === "timeout") {
816
813
  if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
817
814
  const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
815
+ collectEvents();
818
816
  if (!observationClosed && status.running) {
819
- await handleTimeoutResult(new SessionWaitPaused(status, true));
817
+ if (idleStillCurrent())
818
+ await handleTimeoutResult(new SessionWaitPaused(status, true));
820
819
  return;
821
820
  }
822
821
  }
@@ -831,33 +830,102 @@ export class OpenClawClient {
831
830
  checkingState = false;
832
831
  }
833
832
  };
833
+ // One clock for foreground silence and subsequent low-frequency observation.
834
+ // A generation fences status RPCs that complete after newer real activity.
835
+ let idleGeneration = 0;
834
836
  const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
835
837
  if (activity) {
836
838
  if (!foregroundPaused)
837
839
  waitNoticeSent = false;
838
840
  quietNotified = false;
839
841
  }
842
+ const generation = ++idleGeneration;
843
+ const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
840
844
  if (idleTimer)
841
845
  clearTimeout(idleTimer);
842
846
  if (observationClosed)
843
847
  return;
844
848
  idleTimer = setTimeout(async () => {
849
+ // Drain already-received frames before judging silence: the 50ms
850
+ // collection poll may otherwise run just after this deadline.
851
+ collectEvents();
852
+ if (!idleStillCurrent())
853
+ return;
845
854
  if (!quietNotified) {
846
855
  quietNotified = true;
847
856
  console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
848
857
  }
849
- await checkRunState();
850
- if (!observationClosed) {
851
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
852
- if (!observationClosed && status.running) {
853
- await handleTimeoutResult(new SessionWaitPaused(status));
854
- return;
855
- }
858
+ await checkRunState(idleStillCurrent);
859
+ if (!idleStillCurrent())
860
+ return;
861
+ const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
862
+ collectEvents();
863
+ if (!idleStillCurrent())
864
+ return;
865
+ // With a foreground callback, silence freezes presentation even when
866
+ // status is unknown/idle; never invent running. Without one, retain
867
+ // the published running-only typed outcome and background checks.
868
+ if (status.running || options?.onWaitPaused) {
869
+ await handleTimeoutResult(new SessionWaitPaused(status));
870
+ return;
856
871
  }
857
- if (!observationClosed)
858
- resetIdleTimer(60_000, false);
872
+ resetIdleTimer(60_000, false);
859
873
  }, delayMs);
860
874
  };
875
+ // Activity filtering is deliberately separate from reply assembly/matching:
876
+ // legacy session fallback must keep delivering replies as before, but an
877
+ // unrelated/unidentified run must not extend this run's foreground wait.
878
+ const activityKeys = new Set();
879
+ const activityText = new Map();
880
+ const claimActivity = (value) => {
881
+ // Retain fingerprints rather than copies of cumulative/private text.
882
+ const key = createHash("sha256").update(value).digest("hex");
883
+ if (activityKeys.has(key))
884
+ return false;
885
+ activityKeys.add(key);
886
+ return true;
887
+ };
888
+ const hasEffectiveActivity = (ev) => {
889
+ if (!ev.runId || !activeRunIds.has(ev.runId))
890
+ return false;
891
+ const data = ev.data || {};
892
+ const run = String(ev.runId);
893
+ if (ev.stream === "tool" || (ev.stream === "item" && data.kind === "tool")) {
894
+ const phase = data.phase === "result" ? (data.isError || data.error ? "error" : "end") : data.phase;
895
+ if (phase !== "start" && phase !== "end" && phase !== "error")
896
+ return false;
897
+ // Canonical tool and legacy item mirrors share identity and phase.
898
+ // Without a call id, conservatively recognize each name/phase once.
899
+ const id = data.toolCallId || data.itemId || data.id || data.name || data.toolName;
900
+ return Boolean(id) && claimActivity(JSON.stringify([run, "tool", id, phase]));
901
+ }
902
+ if (ev.stream === "lifecycle" && data.phase === "start") {
903
+ return claimActivity(JSON.stringify([run, "start"]));
904
+ }
905
+ if (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") {
906
+ const fullText = typeof data.text === "string" ? data.text : "";
907
+ const chunk = fullText || data.deltaText || data.delta;
908
+ const replace = Boolean(fullText || data.replace);
909
+ if (typeof chunk !== "string" || !chunk.trim())
910
+ return false;
911
+ const source = JSON.stringify([run, ev.stream]);
912
+ // seq identifies repeated incremental frames, not progress by itself.
913
+ // No-seq identical fragments are ambiguous: do not let replay extend idle.
914
+ if (!claimActivity(JSON.stringify([source, ev.seq ?? null, replace, chunk])))
915
+ return false;
916
+ const previous = activityText.get(source) || "";
917
+ const next = replace ? chunk : previous + chunk;
918
+ activityText.set(source, next);
919
+ if (next === previous || (replace && previous.includes(next)))
920
+ return false;
921
+ // Replayed replacement snapshots and assistant/chat/transcript mirrors
922
+ // with the same accumulated text are not additional task progress.
923
+ return claimActivity(JSON.stringify([run, "text", next]));
924
+ }
925
+ // Usage, ticks, user receipts, status/wait responses and unknown streams
926
+ // are not verified task progress. Tool output is counted at completion.
927
+ return false;
928
+ };
861
929
  const summarizeActivity = (ev) => {
862
930
  if (ev.stream === "item") {
863
931
  const data = ev.data || {};
@@ -920,10 +988,12 @@ export class OpenClawClient {
920
988
  clearTimeout(genericChatErrorTimer);
921
989
  resolve(finalText);
922
990
  };
923
- const poller = setInterval(() => {
991
+ const collectEvents = () => {
992
+ if (observationClosed)
993
+ return;
924
994
  // Gateway connection dropped mid-run (e.g. gateway restart): the run will
925
995
  // never deliver a reply on this connection, so stop waiting instead of
926
- // hanging until the 30-min idle timeout. Failing fast lets the bridge
996
+ // hanging until the idle timeout. Failing fast lets the bridge
927
997
  // release its busy lock so new messages are not stuck "waiting".
928
998
  if (startedConnected && !this.connected) {
929
999
  clearInterval(poller);
@@ -974,7 +1044,6 @@ export class OpenClawClient {
974
1044
  bucket.splice(i, 1);
975
1045
  if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
976
1046
  anchorSeen = true;
977
- resetIdleTimer();
978
1047
  for (const pendingEv of preAnchorEvents) {
979
1048
  const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
980
1049
  if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
@@ -1045,10 +1114,8 @@ export class OpenClawClient {
1045
1114
  continue;
1046
1115
  }
1047
1116
  bucket.splice(i, 1);
1048
- // Any matching event — including toolCall/toolResult/item/lifecycle —
1049
- // means the agent is still alive. Use an idle timeout, not an absolute
1050
- // wall-clock timeout, so long tool-heavy tasks are not killed while active.
1051
- resetIdleTimer();
1117
+ if (hasEffectiveActivity(ev))
1118
+ resetIdleTimer();
1052
1119
  rememberActivity(ev);
1053
1120
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
1054
1121
  // was not terminal for the user-visible run. Keep waiting for the real final.
@@ -1115,7 +1182,7 @@ export class OpenClawClient {
1115
1182
  }
1116
1183
  // Finish scanning this batch first so an already-arrived real
1117
1184
  // final/error wins over a stale waiting notice. No status RPC
1118
- // (or ten-minute budget) is needed to confirm this wire fact.
1185
+ // (or ten-minute silence) is needed to confirm this wire fact.
1119
1186
  if (options?.onWaitPaused)
1120
1187
  void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
1121
1188
  continue;
@@ -1233,7 +1300,6 @@ export class OpenClawClient {
1233
1300
  // back to this text.
1234
1301
  if (this.isRecoverableAgentError(errText)) {
1235
1302
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1236
- resetIdleTimer();
1237
1303
  console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
1238
1304
  return;
1239
1305
  }
@@ -1291,7 +1357,6 @@ export class OpenClawClient {
1291
1357
  }
1292
1358
  if (this.isRecoverableAgentError(errText)) {
1293
1359
  pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
1294
- resetIdleTimer();
1295
1360
  console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
1296
1361
  bucket.splice(i, 1);
1297
1362
  continue;
@@ -1323,7 +1388,8 @@ export class OpenClawClient {
1323
1388
  }
1324
1389
  }
1325
1390
  }
1326
- }, 50);
1391
+ };
1392
+ const poller = setInterval(collectEvents, 50);
1327
1393
  const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
1328
1394
  const checkAfterNoActiveRun = async () => {
1329
1395
  await checkRunState();
@@ -1342,29 +1408,12 @@ export class OpenClawClient {
1342
1408
  };
1343
1409
  if (observationKey)
1344
1410
  this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
1345
- // Fixed foreground budget: tool/assistant activity must not keep a card
1346
- // refreshing indefinitely. The original result observer stays alive.
1347
- if (options?.onWaitPaused) {
1348
- foregroundWaitTimer = setTimeout(async () => {
1349
- if (observationClosed || foregroundPaused)
1350
- return;
1351
- await checkRunState();
1352
- if (observationClosed || foregroundPaused)
1353
- return;
1354
- const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
1355
- if (!observationClosed && !foregroundPaused)
1356
- await handleTimeoutResult(new SessionWaitPaused(status));
1357
- }, timeoutMs);
1358
- foregroundWaitTimer.unref?.();
1359
- }
1360
1411
  // Bound local resources without cancelling an execution we cannot prove stuck.
1361
1412
  const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
1362
1413
  observationHorizon.unref?.();
1363
1414
  cleanupObservation = () => {
1364
1415
  observationClosed = true;
1365
1416
  clearTimeout(idleTimer);
1366
- if (foregroundWaitTimer)
1367
- clearTimeout(foregroundWaitTimer);
1368
1417
  clearTimeout(observationHorizon);
1369
1418
  clearInterval(poller);
1370
1419
  if (chatFinalTimer)
@@ -1,4 +1,5 @@
1
- /** Frontend budget only; does not change Gateway execution limits. */
1
+ /** Consecutive effective-activity silence, not a total foreground lifetime.
2
+ * Starts with accepted-request collection; does not change Gateway execution limits. */
2
3
  export declare const FOREGROUND_WAIT_MS: number;
3
4
  export type SessionRuntimeStatus = {
4
5
  status: string;
@@ -1,4 +1,5 @@
1
- /** Frontend budget only; does not change Gateway execution limits. */
1
+ /** Consecutive effective-activity silence, not a total foreground lifetime.
2
+ * Starts with accepted-request collection; does not change Gateway execution limits. */
2
3
  export const FOREGROUND_WAIT_MS = 10 * 60_000;
3
4
  export const isWaitTimeout = (text) => /\btimeout\b|timed\s*out|超时/i.test(text) && !/invalid|validation|schema|malformed|permission denied|forbidden|unauthorized|authentication failed|quota|rate limit|无效参数|参数错误|校验失败/i.test(text);
4
5
  export function normalizeSessionRuntimeStatus(value) {
@@ -35,7 +36,7 @@ export class SessionWaitPaused extends Error {
35
36
  ? "⏳ 本轮已转入后台等待;若有后续结果,将继续在此回复。未发送停止请求,也未自动重发请求。"
36
37
  : reportedTimeout
37
38
  ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
38
- : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
39
+ : "⏳ 本轮连续无有效进展的等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
39
40
  this.snapshot = snapshot;
40
41
  this.reason = reason;
41
42
  this.name = "SessionWaitPaused";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.10",
3
+ "version": "1.4.12",
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": {