openclaw-lark-multi-agent 1.0.23 → 1.1.1

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.
@@ -147,6 +147,12 @@ export declare class FeishuBot {
147
147
  private getChairmanParticipant;
148
148
  private asDiscussionParticipant;
149
149
  private runDiscussionTurn;
150
+ private buildLiveStatusCard;
151
+ /** Minimal escaping so activity text does not break card markdown. */
152
+ private escapeCardText;
153
+ private sendLiveStatusCard;
154
+ private replyLiveStatusCard;
155
+ private patchLiveStatusCard;
150
156
  private sendTextMessage;
151
157
  private replyTextMessage;
152
158
  private editTextMessage;
@@ -1179,15 +1179,13 @@ export class FeishuBot {
1179
1179
  };
1180
1180
  liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
1181
1181
  ? new LiveStatusController({
1182
- create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
1183
- edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1182
+ create: (view) => this.sendOrdered(chatId, () => this.replyLiveStatusCard(lastHuman.messageId || "", view, chatId)),
1183
+ edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
1184
1184
  warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
1185
1185
  }, {
1186
1186
  botName: this.config.name,
1187
+ model: this.config.model,
1187
1188
  locale: this.isEn(chatId) ? "en" : "zh",
1188
- disableHint: this.isEn(chatId)
1189
- ? `Tip: send /livestatus off to hide this status message`
1190
- : `提示:调用 /livestatus off 关闭该状态提示`,
1191
1189
  })
1192
1190
  : undefined;
1193
1191
  liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
@@ -1242,7 +1240,7 @@ export class FeishuBot {
1242
1240
  // submitted far enough that auto-replaying risks duplicate delivery.
1243
1241
  // The user can resend a new message if they want to retry.
1244
1242
  markSubmittedBatch("empty-returned");
1245
- liveStatus?.dispose();
1243
+ await liveStatus?.noReply().catch(() => { });
1246
1244
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1247
1245
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1248
1246
  const remainingAcks = [];
@@ -1342,7 +1340,10 @@ export class FeishuBot {
1342
1340
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1343
1341
  }
1344
1342
  else if (!shouldReply && !hasAttachments) {
1345
- liveStatus?.dispose();
1343
+ // Explicit NO_REPLY (or empty visible text): the model finished without
1344
+ // producing a user-visible reply. Mark the status card done with a
1345
+ // "no content" summary instead of leaving it stuck on "正在执行".
1346
+ await liveStatus?.noReply().catch(() => { });
1346
1347
  }
1347
1348
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1348
1349
  // Replace ack reactions with DONE only for trigger messages actually
@@ -1860,15 +1861,13 @@ export class FeishuBot {
1860
1861
  const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
1861
1862
  const liveStatus = liveStatusEnabled
1862
1863
  ? new LiveStatusController({
1863
- create: (text) => this.sendOrdered(chatId, () => this.sendTextMessage(chatId, text)),
1864
- edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1864
+ create: (view) => this.sendOrdered(chatId, () => this.sendLiveStatusCard(chatId, view)),
1865
+ edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
1865
1866
  warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
1866
1867
  }, {
1867
1868
  botName: this.config.name,
1869
+ model: this.config.model,
1868
1870
  locale: this.isEn(chatId) ? "en" : "zh",
1869
- disableHint: this.isEn(chatId)
1870
- ? `Tip: send /livestatus off to hide this status message`
1871
- : `提示:调用 /livestatus off 关闭该状态提示`,
1872
1871
  })
1873
1872
  : undefined;
1874
1873
  liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
@@ -1929,9 +1928,100 @@ export class FeishuBot {
1929
1928
  throw err;
1930
1929
  }
1931
1930
  }
1932
- await liveStatus?.complete().catch(() => { });
1931
+ // Mirror the normal path: a NO_REPLY/empty discussion turn should finish the
1932
+ // status card with a "no content" summary, not a plain done summary.
1933
+ if (!isVisible && parsedReply.attachments.length === 0) {
1934
+ await liveStatus?.noReply().catch(() => { });
1935
+ }
1936
+ else {
1937
+ await liveStatus?.complete().catch(() => { });
1938
+ }
1933
1939
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1934
1940
  }
1941
+ buildLiveStatusCard(view, chatId) {
1942
+ const en = this.isEn(chatId);
1943
+ const elements = [];
1944
+ // Content area. While running: recent activity lines (each prefixed with the
1945
+ // relative time mm:ss since the run started). When finished: a single run
1946
+ // summary line (total tool calls + elapsed) instead of recent messages.
1947
+ if (view.lines.length > 0) {
1948
+ const iconFor = (kind) => kind === "tool_start" ? "▸"
1949
+ : kind === "tool_end" ? "✓"
1950
+ : kind === "lifecycle" ? "⋯"
1951
+ : kind === "summary" ? "📊"
1952
+ : "•";
1953
+ const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
1954
+ const content = view.lines
1955
+ .map((l) => {
1956
+ const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
1957
+ return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
1958
+ })
1959
+ .join("\n");
1960
+ elements.push({ tag: "markdown", content });
1961
+ }
1962
+ else {
1963
+ elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
1964
+ }
1965
+ elements.push({ tag: "hr" });
1966
+ // Footer: elapsed time + model name.
1967
+ const footerBits = [];
1968
+ footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
1969
+ if (view.model)
1970
+ footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
1971
+ elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
1972
+ // Title color reflects state.
1973
+ const template = view.state === "done" ? "green" : view.state === "failed" ? "orange" : "blue";
1974
+ return {
1975
+ schema: "2.0",
1976
+ // update_multi is REQUIRED for im.message.patch to update the card; Feishu
1977
+ // rejects patches on cards that did not declare it before and after.
1978
+ config: { update_multi: true, width_mode: "fill" },
1979
+ header: {
1980
+ title: { tag: "plain_text", content: view.title },
1981
+ template,
1982
+ },
1983
+ body: { elements },
1984
+ };
1985
+ }
1986
+ /** Minimal escaping so activity text does not break card markdown. */
1987
+ escapeCardText(text) {
1988
+ return String(text || "").replace(/</g, "\uff1c").replace(/>/g, "\uff1e");
1989
+ }
1990
+ async sendLiveStatusCard(chatId, view) {
1991
+ try {
1992
+ const res = await this.client.im.message.create({
1993
+ params: { receive_id_type: "chat_id" },
1994
+ data: {
1995
+ receive_id: chatId,
1996
+ content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
1997
+ msg_type: "interactive",
1998
+ },
1999
+ });
2000
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2001
+ return res?.data?.message_id || res?.message_id;
2002
+ }
2003
+ catch (err) {
2004
+ if (this.isOutOfChatError(err))
2005
+ this.markCurrentBotUnavailable(chatId, err);
2006
+ throw err;
2007
+ }
2008
+ }
2009
+ async replyLiveStatusCard(messageId, view, chatId) {
2010
+ const res = await this.client.im.message.reply({
2011
+ path: { message_id: messageId },
2012
+ data: {
2013
+ content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
2014
+ msg_type: "interactive",
2015
+ },
2016
+ });
2017
+ return res?.data?.message_id || res?.message_id;
2018
+ }
2019
+ async patchLiveStatusCard(messageId, view, chatId) {
2020
+ await this.client.im.message.patch({
2021
+ path: { message_id: messageId },
2022
+ data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
2023
+ });
2024
+ }
1935
2025
  async sendTextMessage(chatId, text) {
1936
2026
  try {
1937
2027
  const res = await this.client.im.message.create({
@@ -2000,11 +2090,12 @@ export class FeishuBot {
2000
2090
  }
2001
2091
  extractBridgeAttachments(reply) {
2002
2092
  const attachments = [];
2003
- // Some models occasionally corrupt the closing tag from
2004
- // </LMA_BRIDGE_ATTACHMENTS> to </parameter> while preserving the exact JSON
2005
- // payload. Treat that as a recoverable marker shape; otherwise the whole
2006
- // marker leaks as user-visible text and attachments are not sent.
2007
- const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
2093
+ // Some models occasionally corrupt the attachment marker while preserving
2094
+ // the exact JSON payload: the closing tag can become </parameter>, or the
2095
+ // opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
2096
+ // Treat these as recoverable shapes; otherwise the marker leaks as
2097
+ // user-visible text and attachments are not sent.
2098
+ const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
2008
2099
  let text = reply.replace(markerPattern, (_match, jsonText) => {
2009
2100
  try {
2010
2101
  const parsed = JSON.parse(String(jsonText).trim());
@@ -1,7 +1,29 @@
1
1
  import type { ProgressEvent } from "./openclaw-client";
2
+ /** One activity line shown in the card content area. */
3
+ export type LiveStatusLine = {
4
+ kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary";
5
+ text: string;
6
+ /** Relative time (seconds since run start) when this line was produced. */
7
+ at: number;
8
+ };
9
+ /** Structured snapshot the renderer turns into a Feishu interactive card. */
10
+ export type LiveStatusView = {
11
+ /** Card title, e.g. "Claude 正在执行" / "✅ Claude 已完成". */
12
+ title: string;
13
+ /** Activity lines (oldest first); when finished this holds a summary instead. */
14
+ lines: LiveStatusLine[];
15
+ /** Footer: elapsed time like "0:42". */
16
+ elapsed: string;
17
+ /** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
18
+ model?: string;
19
+ /** Terminal state: running / done / failed (lets renderer pick color). */
20
+ state: "running" | "done" | "failed";
21
+ };
2
22
  export type LiveStatusCallbacks = {
3
- create: (text: string) => Promise<string | undefined>;
4
- edit: (messageId: string, text: string) => Promise<void>;
23
+ /** Create the status card; returns the new message id. */
24
+ create: (view: LiveStatusView) => Promise<string | undefined>;
25
+ /** Patch the existing status card. */
26
+ edit: (messageId: string, view: LiveStatusView) => Promise<void>;
5
27
  warn?: (message: string, err?: unknown) => void;
6
28
  };
7
29
  export type LiveStatusOptions = {
@@ -9,48 +31,67 @@ export type LiveStatusOptions = {
9
31
  locale?: "zh" | "en";
10
32
  delayMs?: number;
11
33
  maxChars?: number;
12
- maxEdits?: number;
13
- disableHint?: string;
34
+ /** Model name shown in the footer. */
35
+ model?: string;
36
+ /** Number of recent activity lines kept in the content area. */
37
+ historySize?: number;
38
+ /** Footer/auto-refresh cadence in ms. */
39
+ tickMs?: number;
14
40
  };
15
41
  export declare class LiveStatusController {
16
42
  private readonly callbacks;
17
43
  private readonly opts;
18
44
  private messageId?;
19
- private lastSentText;
20
- private detail;
21
- /** Number of edits already issued against the live message (excludes create). */
22
- private editCount;
45
+ private lastSentSignature;
46
+ /** Recent activity lines (most-recent last), capped at historySize. */
47
+ private lines;
48
+ private startedAt;
23
49
  private createTimer?;
50
+ private tickTimer?;
24
51
  private createPromise?;
25
52
  private finalized;
26
53
  private disabled;
27
- /** True after the running-state budget is spent; future progress is ignored. */
28
- private runningLimitReached;
54
+ private state;
55
+ /** Total tool calls (tool_start events) seen during the run, for the summary. */
56
+ private toolCallCount;
57
+ /** True when finished via noReply(): show a "no content" summary. */
58
+ private noReplyResult;
29
59
  constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
30
60
  get id(): string | undefined;
31
61
  start(initialDetail?: string): void;
32
62
  progress(event: ProgressEvent | string): Promise<void>;
33
- /**
34
- * Finish the live status when the real (final) reply is delivered separately.
35
- * The final answer is sent by the normal interactive-card path, so the status
36
- * message must NOT be overwritten with the answer (text type does not render
37
- * Markdown). Feishu renders message deletion as a visible "recalled a message"
38
- * tombstone, so keep the status message and mark it done instead.
39
- */
40
63
  complete(): Promise<void>;
64
+ fail(): Promise<void>;
41
65
  /**
42
- * Finish the live status when the run failed and the error is delivered by the
43
- * normal path. Use a neutral/negative marker so it does not contradict the
44
- * separately delivered error message.
66
+ * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
67
+ * final. Mark the card done but show a "no content" summary instead of the
68
+ * activity window, so the user understands the run finished without output.
45
69
  */
46
- fail(): Promise<void>;
70
+ noReply(): Promise<void>;
47
71
  dispose(): void;
72
+ /**
73
+ * Intermediate assistant text arrives as incremental fragments (each flush
74
+ * sends only the newly-added slice). Consecutive fragments with no tool event
75
+ * in between belong to the same thought, so merge them into ONE line instead
76
+ * of showing each fragment (or each table row) as a separate message. A tool
77
+ * event resets this, so the next text starts a fresh line.
78
+ */
79
+ private appendText;
80
+ private normalizeText;
81
+ private clip;
82
+ private pushLine;
48
83
  private stopTimers;
49
84
  private ensureCreatedNow;
50
85
  private ensureCreated;
51
- private editRunningStatus;
86
+ /** Refresh the elapsed-time footer periodically even with no new activity. */
87
+ private startTicker;
52
88
  private safeEdit;
53
- private formatToolStart;
54
- private formatStatus;
55
- private formatLimitNotice;
89
+ /** Dedupe key includes elapsed so the footer timer ticks even without new
90
+ * activity, while identical content within the same second is still skipped. */
91
+ private signature;
92
+ private isNoReplyText;
93
+ private formatTool;
94
+ private formatElapsed;
95
+ private buildView;
96
+ private buildSummaryLine;
56
97
  }
@@ -1,23 +1,29 @@
1
1
  const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
2
2
  const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
3
- // Feishu caps a single message at 20 edits (code=230072 once exhausted). Keep the
4
- // final edit for the completion marker; running-state updates may consume at most
5
- // maxEdits - 1, with the last running edit reserved for an explicit limit notice.
6
- const DEFAULT_MAX_EDITS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDITS || 20);
3
+ // How many recent activity lines to keep in the card content area.
4
+ const DEFAULT_HISTORY = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY || 6);
5
+ // How often to refresh the elapsed-time footer even when no new activity arrives,
6
+ // so the "已用时间" ticks like a live timer. Interactive-card patch has no 20-edit
7
+ // cap (unlike text edit), so a 1s cadence is fine.
8
+ const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS || 1000);
7
9
  export class LiveStatusController {
8
10
  callbacks;
9
11
  opts;
10
12
  messageId;
11
- lastSentText = "";
12
- detail = "";
13
- /** Number of edits already issued against the live message (excludes create). */
14
- editCount = 0;
13
+ lastSentSignature = "";
14
+ /** Recent activity lines (most-recent last), capped at historySize. */
15
+ lines = [];
16
+ startedAt = Date.now();
15
17
  createTimer;
18
+ tickTimer;
16
19
  createPromise;
17
20
  finalized = false;
18
21
  disabled = false;
19
- /** True after the running-state budget is spent; future progress is ignored. */
20
- runningLimitReached = false;
22
+ state = "running";
23
+ /** Total tool calls (tool_start events) seen during the run, for the summary. */
24
+ toolCallCount = 0;
25
+ /** True when finished via noReply(): show a "no content" summary. */
26
+ noReplyResult = false;
21
27
  constructor(callbacks, opts) {
22
28
  this.callbacks = callbacks;
23
29
  this.opts = opts;
@@ -26,78 +32,138 @@ export class LiveStatusController {
26
32
  start(initialDetail) {
27
33
  if (this.disabled || this.finalized || this.createTimer || this.messageId)
28
34
  return;
29
- this.detail = initialDetail || "";
35
+ this.startedAt = Date.now();
36
+ if (initialDetail && initialDetail.trim()) {
37
+ this.pushLine("lifecycle", initialDetail.trim());
38
+ }
30
39
  this.createTimer = setTimeout(() => {
31
40
  this.createTimer = undefined;
32
41
  this.createPromise = this.ensureCreated();
33
42
  }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
34
43
  }
35
44
  async progress(event) {
36
- if (this.disabled || this.finalized || this.runningLimitReached)
45
+ if (this.disabled || this.finalized)
37
46
  return;
38
- // Only tool-call start is meaningful enough to spend one of Feishu's limited
39
- // edit slots. Ignore verbose assistant notes, lifecycle ticks, tool end, and
40
- // tool error; the final/error answer is delivered by the normal message path.
41
- if (typeof event !== "string") {
42
- if (event.kind !== "tool" || event.phase !== "start")
47
+ // Card patch has no 20-edit cap (verified against Feishu im.message.patch),
48
+ // so we can show a small rolling window of recent activity: tool start, tool
49
+ // end, and intermediate assistant text each count as one line. We ignore
50
+ // tool "error" (the error is delivered through the normal message path) and
51
+ // lifecycle ticks after the first.
52
+ if (typeof event === "string") {
53
+ const t = event.trim();
54
+ if (!t || this.isNoReplyText(t))
55
+ return;
56
+ this.appendText(t);
57
+ }
58
+ else if (event.kind === "tool") {
59
+ if (event.phase === "start") {
60
+ this.toolCallCount++;
61
+ this.pushLine("tool_start", this.formatTool(event));
62
+ }
63
+ else if (event.phase === "end")
64
+ this.pushLine("tool_end", this.formatTool(event));
65
+ else
66
+ return; // tool error -> normal error path
67
+ }
68
+ else if (event.kind === "assistant_note") {
69
+ const t = (event.text || "").trim();
70
+ if (!t || this.isNoReplyText(t))
43
71
  return;
44
- this.detail = this.formatToolStart(event);
72
+ this.appendText(t);
45
73
  }
46
74
  else {
47
- // String progress is kept for tests/backwards compatibility, but in normal
48
- // runtime OpenClaw progress is structured and only tool start passes above.
49
- this.detail = event.trim();
75
+ return; // lifecycle ticks: ignore (footer timer already advances)
50
76
  }
51
- if (!this.detail)
52
- return;
53
77
  await this.ensureCreatedNow();
54
- await this.editRunningStatus();
78
+ await this.safeEdit(this.buildView());
55
79
  }
56
- /**
57
- * Finish the live status when the real (final) reply is delivered separately.
58
- * The final answer is sent by the normal interactive-card path, so the status
59
- * message must NOT be overwritten with the answer (text type does not render
60
- * Markdown). Feishu renders message deletion as a visible "recalled a message"
61
- * tombstone, so keep the status message and mark it done instead.
62
- */
63
80
  async complete() {
81
+ this.state = "done";
64
82
  this.finalized = true;
65
83
  this.stopTimers();
66
84
  if (this.createPromise)
67
85
  await this.createPromise.catch(() => { });
68
86
  if (!this.messageId || this.disabled)
69
87
  return;
70
- const doneText = this.opts.locale === "en"
71
- ? `\u2705 ${this.opts.botName} done`
72
- : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
73
- await this.safeEdit(doneText, true);
88
+ await this.safeEdit(this.buildView(), true);
89
+ }
90
+ async fail() {
91
+ this.state = "failed";
92
+ this.finalized = true;
93
+ this.stopTimers();
94
+ if (this.createPromise)
95
+ await this.createPromise.catch(() => { });
96
+ if (!this.messageId || this.disabled)
97
+ return;
98
+ await this.safeEdit(this.buildView(), true);
74
99
  }
75
100
  /**
76
- * Finish the live status when the run failed and the error is delivered by the
77
- * normal path. Use a neutral/negative marker so it does not contradict the
78
- * separately delivered error message.
101
+ * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
102
+ * final. Mark the card done but show a "no content" summary instead of the
103
+ * activity window, so the user understands the run finished without output.
79
104
  */
80
- async fail() {
105
+ async noReply() {
106
+ this.state = "done";
107
+ this.noReplyResult = true;
81
108
  this.finalized = true;
82
109
  this.stopTimers();
83
110
  if (this.createPromise)
84
111
  await this.createPromise.catch(() => { });
85
112
  if (!this.messageId || this.disabled)
86
113
  return;
87
- const failedText = this.opts.locale === "en"
88
- ? `\u26A0\uFE0F ${this.opts.botName} stopped`
89
- : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
90
- await this.safeEdit(failedText, true);
114
+ await this.safeEdit(this.buildView(), true);
91
115
  }
92
116
  dispose() {
93
117
  this.finalized = true;
94
118
  this.stopTimers();
95
119
  }
120
+ /**
121
+ * Intermediate assistant text arrives as incremental fragments (each flush
122
+ * sends only the newly-added slice). Consecutive fragments with no tool event
123
+ * in between belong to the same thought, so merge them into ONE line instead
124
+ * of showing each fragment (or each table row) as a separate message. A tool
125
+ * event resets this, so the next text starts a fresh line.
126
+ */
127
+ appendText(fragment) {
128
+ const last = this.lines[this.lines.length - 1];
129
+ if (last && last.kind === "text") {
130
+ const merged = `${last.text} ${this.normalizeText(fragment)}`.replace(/\s+/g, " ").trim();
131
+ last.text = this.clip(merged);
132
+ return;
133
+ }
134
+ this.pushLine("text", fragment);
135
+ }
136
+ normalizeText(text) {
137
+ return text.replace(/\s+/g, " ").trim();
138
+ }
139
+ clip(text) {
140
+ const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
141
+ return text.length > maxChars ? `${text.slice(0, Math.max(0, maxChars - 1))}\u2026` : text;
142
+ }
143
+ pushLine(kind, text) {
144
+ const clean = this.normalizeText(text);
145
+ if (!clean)
146
+ return;
147
+ const clipped = this.clip(clean);
148
+ // Collapse consecutive identical lines (e.g. repeated "等待 OpenClaw").
149
+ const last = this.lines[this.lines.length - 1];
150
+ if (last && last.kind === kind && last.text === clipped)
151
+ return;
152
+ const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
153
+ this.lines.push({ kind, text: clipped, at });
154
+ const limit = this.opts.historySize ?? DEFAULT_HISTORY;
155
+ if (this.lines.length > limit)
156
+ this.lines = this.lines.slice(this.lines.length - limit);
157
+ }
96
158
  stopTimers() {
97
159
  if (this.createTimer) {
98
160
  clearTimeout(this.createTimer);
99
161
  this.createTimer = undefined;
100
162
  }
163
+ if (this.tickTimer) {
164
+ clearInterval(this.tickTimer);
165
+ this.tickTimer = undefined;
166
+ }
101
167
  }
102
168
  async ensureCreatedNow() {
103
169
  if (this.createTimer) {
@@ -111,45 +177,45 @@ export class LiveStatusController {
111
177
  async ensureCreated() {
112
178
  if (this.disabled || this.finalized || this.messageId)
113
179
  return;
114
- const text = this.formatStatus(true);
180
+ const view = this.buildView();
115
181
  try {
116
- const id = await this.callbacks.create(text);
182
+ const id = await this.callbacks.create(view);
117
183
  if (!id) {
118
184
  this.disabled = true;
119
185
  return;
120
186
  }
121
187
  this.messageId = id;
122
- this.lastSentText = text;
188
+ this.lastSentSignature = this.signature(view);
189
+ this.startTicker();
123
190
  }
124
191
  catch (err) {
125
192
  this.disabled = true;
126
193
  this.callbacks.warn?.("live status create failed", err);
127
194
  }
128
195
  }
129
- async editRunningStatus() {
130
- if (!this.messageId || this.disabled || this.finalized || this.runningLimitReached)
131
- return;
132
- const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
133
- const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
134
- if (this.editCount >= runningBudget)
196
+ /** Refresh the elapsed-time footer periodically even with no new activity. */
197
+ startTicker() {
198
+ if (this.tickTimer || this.disabled || this.finalized)
135
199
  return;
136
- const isLimitNotice = this.editCount === runningBudget - 1;
137
- const text = isLimitNotice ? this.formatLimitNotice() : this.formatStatus();
138
- const ok = await this.safeEdit(text);
139
- if (ok && isLimitNotice)
140
- this.runningLimitReached = true;
200
+ const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
201
+ this.tickTimer = setInterval(() => {
202
+ if (this.finalized || this.disabled || !this.messageId)
203
+ return;
204
+ void this.safeEdit(this.buildView());
205
+ }, tickMs);
206
+ this.tickTimer.unref?.();
141
207
  }
142
- async safeEdit(text, force = false) {
208
+ async safeEdit(view, force = false) {
143
209
  if (!this.messageId || this.disabled)
144
210
  return false;
145
211
  if (!force && this.finalized)
146
212
  return false;
147
- if (!force && text === this.lastSentText)
213
+ const sig = this.signature(view);
214
+ if (!force && sig === this.lastSentSignature)
148
215
  return true;
149
216
  try {
150
- await this.callbacks.edit(this.messageId, text);
151
- this.lastSentText = text;
152
- this.editCount++;
217
+ await this.callbacks.edit(this.messageId, view);
218
+ this.lastSentSignature = sig;
153
219
  return true;
154
220
  }
155
221
  catch (err) {
@@ -159,26 +225,62 @@ export class LiveStatusController {
159
225
  return false;
160
226
  }
161
227
  }
162
- formatToolStart(event) {
228
+ /** Dedupe key includes elapsed so the footer timer ticks even without new
229
+ * activity, while identical content within the same second is still skipped. */
230
+ signature(view) {
231
+ return `${view.state}|${view.title}|${view.elapsed}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
232
+ }
233
+ isNoReplyText(text) {
234
+ return text.trim().toUpperCase() === "NO_REPLY";
235
+ }
236
+ formatTool(event) {
163
237
  const raw = event.text || event.name || "";
164
- const prefix = `${event.name} start`;
238
+ const prefix = `${event.name} ${event.phase}`;
165
239
  let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
166
240
  if (detail.startsWith(":"))
167
241
  detail = detail.slice(1).trim();
168
242
  return detail ? `${event.name}: ${detail}` : event.name;
169
243
  }
170
- formatStatus(includeDisableHint = false) {
171
- const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
172
- const clean = (this.detail || "").replace(/\s+/g, " ").trim();
173
- const clipped = clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 1))}\u2026` : clean;
174
- const base = !clipped
175
- ? (this.opts.locale === "en" ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`)
176
- : (this.opts.locale === "en" ? `${this.opts.botName} is working: ${clipped}` : `${this.opts.botName} \u6b63\u5728\u6267\u884c\uff1a${clipped}`);
177
- return includeDisableHint && this.opts.disableHint ? `${base}\n${this.opts.disableHint}` : base;
178
- }
179
- formatLimitNotice() {
180
- return this.opts.locale === "en"
181
- ? `${this.opts.botName} reached the update limit and is still running`
182
- : `${this.opts.botName} \u5df2\u8fbe\u66f4\u65b0\u9650\u5236\uff0c\u6b63\u5728\u6301\u7eed\u6267\u884c\u4e2d`;
244
+ formatElapsed() {
245
+ const totalSec = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
246
+ const m = Math.floor(totalSec / 60);
247
+ const s = totalSec % 60;
248
+ return `${m}:${String(s).padStart(2, "0")}`;
249
+ }
250
+ buildView() {
251
+ const en = this.opts.locale === "en";
252
+ let title;
253
+ if (this.state === "done")
254
+ title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
255
+ else if (this.state === "failed")
256
+ title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
257
+ else
258
+ title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
259
+ // When finished, replace the recent-activity window with a run summary
260
+ // (total tool calls + total time) instead of the last few messages.
261
+ const lines = this.state === "running"
262
+ ? [...this.lines]
263
+ : [this.buildSummaryLine(en)];
264
+ return {
265
+ title,
266
+ lines,
267
+ elapsed: this.formatElapsed(),
268
+ model: this.opts.model,
269
+ state: this.state,
270
+ };
271
+ }
272
+ buildSummaryLine(en) {
273
+ const elapsed = this.formatElapsed();
274
+ const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
275
+ if (this.noReplyResult) {
276
+ const text = en
277
+ ? `Model returned no content \u00b7 ${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
278
+ : `\u6a21\u578b\u6ca1\u6709\u56de\u590d\u5185\u5bb9 \u00b7 \u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
279
+ return { kind: "summary", text, at };
280
+ }
281
+ const text = en
282
+ ? `${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
283
+ : `\u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
284
+ return { kind: "summary", text, at };
183
285
  }
184
286
  }
@@ -90,6 +90,15 @@ export declare class OpenClawClient {
90
90
  * 30-minute safety net only for catastrophic WS disconnection.
91
91
  */
92
92
  private collectReply;
93
+ /**
94
+ * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
95
+ * (it auto-compacts and keeps running the same run, then emits a real final).
96
+ * For these we must not reject collectReply immediately, or the bridge reports
97
+ * "没有完成回复" while the agent is still working. Currently this covers
98
+ * context-overflow / prompt-too-large, which OpenClaw recovers from via its
99
+ * context-overflow-recovery + auto-compaction path.
100
+ */
101
+ private isRecoverableAgentError;
93
102
  private pickBestCollectedText;
94
103
  createSession(params: {
95
104
  key: string;
@@ -810,13 +810,29 @@ export class OpenClawClient {
810
810
  return;
811
811
  }
812
812
  if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
813
+ const errText = String(ev.data?.error || "unknown");
814
+ // Some agent errors are recoverable: OpenClaw emits a lifecycle
815
+ // error (e.g. "Context overflow: prompt too large") and then
816
+ // auto-compacts and KEEPS RUNNING the same run, eventually emitting
817
+ // a real final. If we reject immediately the user sees a scary
818
+ // "没有完成回复" while the agent is in fact still working. So for
819
+ // recoverable errors we defer: record the failure text and keep
820
+ // waiting. Subsequent delta/item events reset the idle timer, so a
821
+ // real final wins; if the run truly stalls, the idle timeout falls
822
+ // back to this text.
823
+ if (this.isRecoverableAgentError(errText)) {
824
+ pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
825
+ resetIdleTimer();
826
+ console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
827
+ return;
828
+ }
813
829
  clearTimeout(idleTimer);
814
830
  clearInterval(poller);
815
831
  if (chatFinalTimer)
816
832
  clearTimeout(chatFinalTimer);
817
833
  if (lifecycleEndTimer)
818
834
  clearTimeout(lifecycleEndTimer);
819
- reject(new Error(`Agent error: ${ev.data?.error || "unknown"}`));
835
+ reject(new Error(`Agent error: ${errText}`));
820
836
  return;
821
837
  }
822
838
  }
@@ -824,6 +840,22 @@ export class OpenClawClient {
824
840
  }, 50);
825
841
  });
826
842
  }
843
+ /**
844
+ * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
845
+ * (it auto-compacts and keeps running the same run, then emits a real final).
846
+ * For these we must not reject collectReply immediately, or the bridge reports
847
+ * "没有完成回复" while the agent is still working. Currently this covers
848
+ * context-overflow / prompt-too-large, which OpenClaw recovers from via its
849
+ * context-overflow-recovery + auto-compaction path.
850
+ */
851
+ isRecoverableAgentError(errText) {
852
+ const t = (errText || "").toLowerCase();
853
+ return t.includes("context overflow")
854
+ || t.includes("prompt too large")
855
+ || t.includes("context length")
856
+ || t.includes("too many tokens")
857
+ || t.includes("maximum context");
858
+ }
827
859
  pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
828
860
  if (chatFinalText)
829
861
  return chatFinalText;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.23",
3
+ "version": "1.1.1",
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": {