openclaw-lark-multi-agent 1.0.21 → 1.0.23

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.
@@ -0,0 +1,56 @@
1
+ import type { ProgressEvent } from "./openclaw-client";
2
+ export type LiveStatusCallbacks = {
3
+ create: (text: string) => Promise<string | undefined>;
4
+ edit: (messageId: string, text: string) => Promise<void>;
5
+ warn?: (message: string, err?: unknown) => void;
6
+ };
7
+ export type LiveStatusOptions = {
8
+ botName: string;
9
+ locale?: "zh" | "en";
10
+ delayMs?: number;
11
+ maxChars?: number;
12
+ maxEdits?: number;
13
+ disableHint?: string;
14
+ };
15
+ export declare class LiveStatusController {
16
+ private readonly callbacks;
17
+ private readonly opts;
18
+ private messageId?;
19
+ private lastSentText;
20
+ private detail;
21
+ /** Number of edits already issued against the live message (excludes create). */
22
+ private editCount;
23
+ private createTimer?;
24
+ private createPromise?;
25
+ private finalized;
26
+ private disabled;
27
+ /** True after the running-state budget is spent; future progress is ignored. */
28
+ private runningLimitReached;
29
+ constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
30
+ get id(): string | undefined;
31
+ start(initialDetail?: string): void;
32
+ 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
+ complete(): Promise<void>;
41
+ /**
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.
45
+ */
46
+ fail(): Promise<void>;
47
+ dispose(): void;
48
+ private stopTimers;
49
+ private ensureCreatedNow;
50
+ private ensureCreated;
51
+ private editRunningStatus;
52
+ private safeEdit;
53
+ private formatToolStart;
54
+ private formatStatus;
55
+ private formatLimitNotice;
56
+ }
@@ -0,0 +1,184 @@
1
+ const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
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);
7
+ export class LiveStatusController {
8
+ callbacks;
9
+ opts;
10
+ messageId;
11
+ lastSentText = "";
12
+ detail = "";
13
+ /** Number of edits already issued against the live message (excludes create). */
14
+ editCount = 0;
15
+ createTimer;
16
+ createPromise;
17
+ finalized = false;
18
+ disabled = false;
19
+ /** True after the running-state budget is spent; future progress is ignored. */
20
+ runningLimitReached = false;
21
+ constructor(callbacks, opts) {
22
+ this.callbacks = callbacks;
23
+ this.opts = opts;
24
+ }
25
+ get id() { return this.messageId; }
26
+ start(initialDetail) {
27
+ if (this.disabled || this.finalized || this.createTimer || this.messageId)
28
+ return;
29
+ this.detail = initialDetail || "";
30
+ this.createTimer = setTimeout(() => {
31
+ this.createTimer = undefined;
32
+ this.createPromise = this.ensureCreated();
33
+ }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
34
+ }
35
+ async progress(event) {
36
+ if (this.disabled || this.finalized || this.runningLimitReached)
37
+ 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")
43
+ return;
44
+ this.detail = this.formatToolStart(event);
45
+ }
46
+ 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();
50
+ }
51
+ if (!this.detail)
52
+ return;
53
+ await this.ensureCreatedNow();
54
+ await this.editRunningStatus();
55
+ }
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
+ async complete() {
64
+ this.finalized = true;
65
+ this.stopTimers();
66
+ if (this.createPromise)
67
+ await this.createPromise.catch(() => { });
68
+ if (!this.messageId || this.disabled)
69
+ 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);
74
+ }
75
+ /**
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.
79
+ */
80
+ async fail() {
81
+ this.finalized = true;
82
+ this.stopTimers();
83
+ if (this.createPromise)
84
+ await this.createPromise.catch(() => { });
85
+ if (!this.messageId || this.disabled)
86
+ 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);
91
+ }
92
+ dispose() {
93
+ this.finalized = true;
94
+ this.stopTimers();
95
+ }
96
+ stopTimers() {
97
+ if (this.createTimer) {
98
+ clearTimeout(this.createTimer);
99
+ this.createTimer = undefined;
100
+ }
101
+ }
102
+ async ensureCreatedNow() {
103
+ if (this.createTimer) {
104
+ clearTimeout(this.createTimer);
105
+ this.createTimer = undefined;
106
+ this.createPromise = this.ensureCreated();
107
+ }
108
+ if (this.createPromise)
109
+ await this.createPromise.catch(() => { });
110
+ }
111
+ async ensureCreated() {
112
+ if (this.disabled || this.finalized || this.messageId)
113
+ return;
114
+ const text = this.formatStatus(true);
115
+ try {
116
+ const id = await this.callbacks.create(text);
117
+ if (!id) {
118
+ this.disabled = true;
119
+ return;
120
+ }
121
+ this.messageId = id;
122
+ this.lastSentText = text;
123
+ }
124
+ catch (err) {
125
+ this.disabled = true;
126
+ this.callbacks.warn?.("live status create failed", err);
127
+ }
128
+ }
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)
135
+ 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;
141
+ }
142
+ async safeEdit(text, force = false) {
143
+ if (!this.messageId || this.disabled)
144
+ return false;
145
+ if (!force && this.finalized)
146
+ return false;
147
+ if (!force && text === this.lastSentText)
148
+ return true;
149
+ try {
150
+ await this.callbacks.edit(this.messageId, text);
151
+ this.lastSentText = text;
152
+ this.editCount++;
153
+ return true;
154
+ }
155
+ catch (err) {
156
+ this.disabled = true;
157
+ this.stopTimers();
158
+ this.callbacks.warn?.("live status edit failed", err);
159
+ return false;
160
+ }
161
+ }
162
+ formatToolStart(event) {
163
+ const raw = event.text || event.name || "";
164
+ const prefix = `${event.name} start`;
165
+ let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
166
+ if (detail.startsWith(":"))
167
+ detail = detail.slice(1).trim();
168
+ return detail ? `${event.name}: ${detail}` : event.name;
169
+ }
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`;
183
+ }
184
+ }
@@ -70,6 +70,12 @@ export declare class MessageStore {
70
70
  enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
71
71
  getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
72
72
  getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
73
+ markBotSeenInChat(botName: string, chatId: string): void;
74
+ hasBotSeenInChat(botName: string, chatId: string): boolean;
75
+ markBotUnavailableInChat(botName: string, chatId: string, reason?: string): void;
76
+ clearBotUnavailableInChat(botName: string, chatId: string): void;
77
+ isBotUnavailableInChat(botName: string, chatId: string): boolean;
78
+ hasAnyDeliveredToChat(botName: string, chatId: string): boolean;
73
79
  hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
74
80
  hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
75
81
  resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
@@ -119,6 +125,8 @@ export declare class MessageStore {
119
125
  getChairmanBot(chatId: string): string;
120
126
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
121
127
  getBotVerbose(botName: string, chatId: string): boolean;
128
+ setBotLiveStatus(botName: string, chatId: string, on: boolean): void;
129
+ getBotLiveStatus(botName: string, chatId: string): boolean;
122
130
  setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
123
131
  getBotMode(botName: string, chatId: string): BotChatMode;
124
132
  setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
@@ -104,12 +104,34 @@ export class MessageStore {
104
104
  bot_name TEXT NOT NULL,
105
105
  chat_id TEXT NOT NULL,
106
106
  verbose INTEGER NOT NULL DEFAULT 0,
107
+ live_status INTEGER NOT NULL DEFAULT 1,
107
108
  free_discussion INTEGER NOT NULL DEFAULT 0,
108
109
  mode TEXT NOT NULL DEFAULT 'normal',
109
110
  updated_at INTEGER NOT NULL DEFAULT 0,
110
111
  PRIMARY KEY (bot_name, chat_id)
111
112
  );
112
113
 
114
+ -- Durable positive membership signal. Feishu does not reliably expose bot
115
+ -- members via chat_members, so receiving an event in a chat is the strongest
116
+ -- proof that this bot app is actually installed in that group.
117
+ CREATE TABLE IF NOT EXISTS bot_chat_seen (
118
+ bot_name TEXT NOT NULL,
119
+ chat_id TEXT NOT NULL,
120
+ updated_at INTEGER NOT NULL,
121
+ PRIMARY KEY (bot_name, chat_id)
122
+ );
123
+
124
+ -- Durable negative membership cache. Feishu message create returns 230002
125
+ -- when a bot app is not in a chat; cache that so discussion scheduling and
126
+ -- later proactive deliveries do not keep generating ghost participants.
127
+ CREATE TABLE IF NOT EXISTS bot_chat_unavailable (
128
+ bot_name TEXT NOT NULL,
129
+ chat_id TEXT NOT NULL,
130
+ reason TEXT NOT NULL DEFAULT '',
131
+ updated_at INTEGER NOT NULL,
132
+ PRIMARY KEY (bot_name, chat_id)
133
+ );
134
+
113
135
  -- Durable delivery ledger for user-visible assistant outputs. All final
114
136
  -- text/attachment outputs should be inserted here first and dispatched
115
137
  -- once, regardless of whether they came from chat final, proactive
@@ -135,6 +157,13 @@ export class MessageStore {
135
157
  );
136
158
  CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
137
159
  `);
160
+ // Migration: add per-bot live status toggle if missing
161
+ try {
162
+ this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN live_status INTEGER NOT NULL DEFAULT 1`);
163
+ }
164
+ catch {
165
+ // Column already exists
166
+ }
138
167
  // Migration: add delivery_outbox delivery key columns if missing
139
168
  try {
140
169
  this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
@@ -401,6 +430,42 @@ export class MessageStore {
401
430
  `).all(maxCount);
402
431
  return rows.map((r) => this.mapDelivery(r));
403
432
  }
433
+ markBotSeenInChat(botName, chatId) {
434
+ this.db.prepare(`
435
+ INSERT INTO bot_chat_seen(bot_name, chat_id, updated_at)
436
+ VALUES (?, ?, ?)
437
+ ON CONFLICT(bot_name, chat_id) DO UPDATE SET updated_at = excluded.updated_at
438
+ `).run(botName, chatId, Date.now());
439
+ // If the bot is receiving events from this chat again, it is no longer out
440
+ // of the chat. This recovers automatically when a bot is re-added to a group.
441
+ this.clearBotUnavailableInChat(botName, chatId);
442
+ }
443
+ hasBotSeenInChat(botName, chatId) {
444
+ const row = this.db.prepare(`SELECT 1 FROM bot_chat_seen WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
445
+ return !!row;
446
+ }
447
+ markBotUnavailableInChat(botName, chatId, reason = '') {
448
+ this.db.prepare(`
449
+ INSERT INTO bot_chat_unavailable(bot_name, chat_id, reason, updated_at)
450
+ VALUES (?, ?, ?, ?)
451
+ ON CONFLICT(bot_name, chat_id) DO UPDATE SET reason = excluded.reason, updated_at = excluded.updated_at
452
+ `).run(botName, chatId, reason, Date.now());
453
+ }
454
+ clearBotUnavailableInChat(botName, chatId) {
455
+ this.db.prepare(`DELETE FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ?`).run(botName, chatId);
456
+ }
457
+ isBotUnavailableInChat(botName, chatId) {
458
+ const row = this.db.prepare(`SELECT 1 FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
459
+ return !!row;
460
+ }
461
+ hasAnyDeliveredToChat(botName, chatId) {
462
+ const row = this.db.prepare(`
463
+ SELECT 1 FROM delivery_outbox
464
+ WHERE bot_name = ? AND chat_id = ? AND status = 'delivered'
465
+ LIMIT 1
466
+ `).get(botName, chatId);
467
+ return !!row;
468
+ }
404
469
  hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs, sourceTypes) {
405
470
  if (!contentHash)
406
471
  return false;
@@ -694,6 +759,22 @@ export class MessageStore {
694
759
  `).get(botName, chatId);
695
760
  return !!row?.verbose;
696
761
  }
762
+ setBotLiveStatus(botName, chatId, on) {
763
+ this.db.prepare(`
764
+ INSERT INTO bot_chat_settings (bot_name, chat_id, live_status, updated_at)
765
+ VALUES (?, ?, ?, ?)
766
+ ON CONFLICT (bot_name, chat_id) DO UPDATE SET
767
+ live_status = excluded.live_status,
768
+ updated_at = excluded.updated_at
769
+ `).run(botName, chatId, on ? 1 : 0, Date.now());
770
+ }
771
+ getBotLiveStatus(botName, chatId) {
772
+ const row = this.db.prepare(`
773
+ SELECT live_status FROM bot_chat_settings
774
+ WHERE bot_name = ? AND chat_id = ?
775
+ `).get(botName, chatId);
776
+ return row ? row.live_status !== 0 : true;
777
+ }
697
778
  setBotMode(botName, chatId, mode) {
698
779
  const freeDiscussion = mode === "free" ? 1 : 0;
699
780
  this.db.prepare(`
@@ -1,5 +1,17 @@
1
1
  import { OpenClawConfig } from "./config.js";
2
2
  import { ChatMessage } from "./message-store.js";
3
+ export type ProgressEvent = {
4
+ kind: "tool";
5
+ phase: "start" | "end" | "error";
6
+ name: string;
7
+ text: string;
8
+ } | {
9
+ kind: "assistant_note";
10
+ text: string;
11
+ } | {
12
+ kind: "lifecycle";
13
+ text: string;
14
+ };
3
15
  export type ChatAttachment = {
4
16
  type?: string;
5
17
  mimeType?: string;
@@ -26,6 +38,7 @@ export declare class OpenClawClient {
26
38
  private toolEventCallbacks;
27
39
  private lastToolNamesByItemId;
28
40
  private sessionMessageCallbacks;
41
+ private progressCallbacks;
29
42
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
30
43
  private verboseTranscriptSessions;
31
44
  private verboseAssistantTimers;
@@ -138,6 +151,7 @@ export declare class OpenClawClient {
138
151
  onSendAttempt?: () => void | Promise<void>;
139
152
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
140
153
  onSubmitted?: (runId: string) => void | Promise<void>;
154
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
141
155
  }): Promise<string>;
142
156
  private shouldInjectBridgeAttachmentHint;
143
157
  private bridgeAttachmentHint;
@@ -170,6 +184,7 @@ export declare class OpenClawClient {
170
184
  onSendAttempt?: () => void | Promise<void>;
171
185
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
172
186
  onSubmitted?: (runId: string) => void | Promise<void>;
187
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
173
188
  }): Promise<string>;
174
189
  private formatContextLines;
175
190
  private writeContextSyncFile;
@@ -29,6 +29,7 @@ export class OpenClawClient {
29
29
  toolEventCallbacks = new Map();
30
30
  lastToolNamesByItemId = new Map();
31
31
  sessionMessageCallbacks = new Map();
32
+ progressCallbacks = new Map();
32
33
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
33
34
  verboseTranscriptSessions = new Set();
34
35
  verboseAssistantTimers = new Map();
@@ -239,11 +240,17 @@ export class OpenClawClient {
239
240
  this.flushVerboseAssistantState(rawKey);
240
241
  this.clearVerboseAssistantState(rawKey);
241
242
  }
242
- if (toolCb && data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
+ if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
244
  const phase = data.phase || "event";
244
245
  const meta = data.meta || data.input || data.args || "";
245
246
  const output = data.output || data.result || data.error || "";
246
- toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
247
+ if (toolCb)
248
+ toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
249
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
250
+ if (progressCb) {
251
+ const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
252
+ void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
253
+ }
247
254
  }
248
255
  }
249
256
  if (frame.event === "agent" && frame.payload?.stream === "command_output") {
@@ -348,11 +355,12 @@ export class OpenClawClient {
348
355
  }
349
356
  }
350
357
  flushVerboseAssistantState(rawKey) {
351
- if (!this.isVerboseTranscriptEnabled(rawKey))
352
- return false;
353
358
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
354
359
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
355
- if (!cb)
360
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
361
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
362
+ return false;
363
+ if (!cb && !progressCb)
356
364
  return false;
357
365
  const key = rawKey;
358
366
  const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
@@ -368,16 +376,22 @@ export class OpenClawClient {
368
376
  if (!toSend)
369
377
  return false;
370
378
  this.verboseAssistantSent.set(key, latest);
371
- cb(toSend, { sourceType: "verbose_transcript" });
379
+ if (this.isVerboseTranscriptEnabled(rawKey) && cb)
380
+ cb(toSend, { sourceType: "verbose_transcript" });
381
+ if (progressCb)
382
+ void progressCb({ kind: "assistant_note", text: toSend });
372
383
  return true;
373
384
  }
374
385
  handleVerboseAssistantStream(payload) {
375
386
  const rawKey = payload?.sessionKey || "";
376
- if (!rawKey || !this.isVerboseTranscriptEnabled(rawKey))
387
+ if (!rawKey)
377
388
  return;
378
389
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
379
390
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
380
- if (!cb)
391
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
392
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
393
+ return;
394
+ if (!cb && !progressCb)
381
395
  return;
382
396
  const data = payload.data || {};
383
397
  const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
@@ -1090,6 +1104,11 @@ export class OpenClawClient {
1090
1104
  }
1091
1105
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
1092
1106
  await params.onSubmitted?.(result.runId);
1107
+ if (params.onProgress) {
1108
+ this.progressCallbacks.set(sk, params.onProgress);
1109
+ this.progressCallbacks.set(fullSessionKey, params.onProgress);
1110
+ void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
1111
+ }
1093
1112
  return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
1094
1113
  }
1095
1114
  finally {
@@ -1097,6 +1116,8 @@ export class OpenClawClient {
1097
1116
  // collectReply returns. Keep a short grace window so normal chat replies
1098
1117
  // are not delivered twice via the proactive-message path. Cron/LMA runs
1099
1118
  // are unaffected because they do not go through chatSend.
1119
+ this.progressCallbacks.delete(sk);
1120
+ this.progressCallbacks.delete(fullSessionKey);
1100
1121
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
1101
1122
  this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
1102
1123
  }
@@ -1149,6 +1170,7 @@ export class OpenClawClient {
1149
1170
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1150
1171
  onSendAttempt: params.onSendAttempt,
1151
1172
  onSubmitted: params.onSubmitted,
1173
+ onProgress: params.onProgress,
1152
1174
  });
1153
1175
  }
1154
1176
  // Build context block + actual message in one chat.send, or write the
@@ -1184,6 +1206,7 @@ export class OpenClawClient {
1184
1206
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1185
1207
  onSendAttempt: params.onSendAttempt,
1186
1208
  onSubmitted: params.onSubmitted,
1209
+ onProgress: params.onProgress,
1187
1210
  });
1188
1211
  }
1189
1212
  formatContextLines(messages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
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": {