openclaw-lark-multi-agent 1.4.8 → 1.4.10
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 +18 -0
- package/README.zh-CN.md +16 -0
- package/dist/feishu-bot.d.ts +4 -0
- package/dist/feishu-bot.js +147 -32
- package/dist/live-status.d.ts +11 -0
- package/dist/live-status.js +58 -4
- package/dist/openclaw-client.d.ts +15 -2
- package/dist/openclaw-client.js +336 -44
- package/dist/session-status.d.ts +19 -0
- package/dist/session-status.js +43 -0
- package/package.json +4 -4
- package/plugins/lma-steer/README.md +10 -0
- package/plugins/lma-steer/openclaw.plugin.json +57 -2
- package/plugins/lma-steer/package.json +4 -2
package/README.md
CHANGED
|
@@ -590,3 +590,21 @@ If a secret is ever committed, remove it from the current tree and rewrite git h
|
|
|
590
590
|
## License
|
|
591
591
|
|
|
592
592
|
MIT
|
|
593
|
+
|
|
594
|
+
### Reply status and context usage (v1.4.9)
|
|
595
|
+
|
|
596
|
+
Final text replies include a compact footer, for example:
|
|
597
|
+
|
|
598
|
+
`🧠 provider/model · running · 85K/200K`
|
|
599
|
+
|
|
600
|
+
State and context usage come from one bounded Gateway status lookup. K values
|
|
601
|
+
are rounded integers (1000 tokens per K); unavailable/stale values are not shown
|
|
602
|
+
as zero. The footer is a send-time snapshot, not a live status widget.
|
|
603
|
+
|
|
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
|
|
607
|
+
result handling and the original queue remain, with no automatic abort/replay.
|
|
608
|
+
Real results are sent separately; necessary terminal card cleanup is retained.
|
|
609
|
+
This does not shorten the Gateway's execution limit. Unverified steer V2 and
|
|
610
|
+
question adapters are not included in this release.
|
package/README.zh-CN.md
CHANGED
|
@@ -498,3 +498,19 @@ git ls-files | grep -E 'config|secret|\.env' || true
|
|
|
498
498
|
## License
|
|
499
499
|
|
|
500
500
|
MIT
|
|
501
|
+
|
|
502
|
+
### 回复状态与上下文用量(v1.4.9)
|
|
503
|
+
|
|
504
|
+
最终文字回复使用紧凑尾部,例如:
|
|
505
|
+
|
|
506
|
+
`🧠 provider/model · running · 85K/200K`
|
|
507
|
+
|
|
508
|
+
状态与上下文已用量/上限来自同一次、有等待上限的 Gateway 查询;K 按
|
|
509
|
+
1000 token 四舍五入为整数。缺失或明确过期的数据不冒充 0。尾部反映发送前
|
|
510
|
+
查询的状态,不会持续自动刷新历史回复。
|
|
511
|
+
|
|
512
|
+
前台结果等待与过程卡刷新预算调整为 10 分钟。会话仍在 running 时发送普通
|
|
513
|
+
等待提示,不把它当作整个任务执行失败;旧卡不再周期刷新或随工具进度编辑。
|
|
514
|
+
后台仍监听原任务,保留原队列,不因此停止或自动重发请求。真实结果另行回复,
|
|
515
|
+
保留必要的终态收尾。本改动不缩短 Gateway 的执行时限,也不包含未验收的
|
|
516
|
+
steer V2 与问答适配器。
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export declare class FeishuBot {
|
|
|
40
40
|
* command/error/card sends with model attribution. */
|
|
41
41
|
private replyModelFooters;
|
|
42
42
|
private sendModelFooters;
|
|
43
|
+
private replyStatusFooters;
|
|
44
|
+
private sendStatusFooters;
|
|
43
45
|
/** Per-chat durable outbox retry timer. */
|
|
44
46
|
private deliveryRetryTimers;
|
|
45
47
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
@@ -137,6 +139,8 @@ export declare class FeishuBot {
|
|
|
137
139
|
/** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
|
|
138
140
|
* outbox entries that once targeted the live-status card. */
|
|
139
141
|
private patchLiveStatusDoneSummary;
|
|
142
|
+
private readSessionStatus;
|
|
143
|
+
private sendWaitPaused;
|
|
140
144
|
private formatUserVisibleError;
|
|
141
145
|
private isBridgeControlReply;
|
|
142
146
|
private isLegacyBridgeControlMessage;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -2,6 +2,8 @@ import * as lark from "@larksuiteoapi/node-sdk";
|
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { persistBotModel } from "./config.js";
|
|
4
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
5
|
+
import { InactiveRunObservation } from "./openclaw-client.js";
|
|
6
|
+
import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
5
7
|
import { LiveStatusController } from "./live-status.js";
|
|
6
8
|
import { CompactProgressController } from "./compact-progress.js";
|
|
7
9
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
@@ -98,6 +100,8 @@ export class FeishuBot {
|
|
|
98
100
|
* command/error/card sends with model attribution. */
|
|
99
101
|
replyModelFooters = new Map();
|
|
100
102
|
sendModelFooters = new Map();
|
|
103
|
+
replyStatusFooters = new Map();
|
|
104
|
+
sendStatusFooters = new Map();
|
|
101
105
|
/** Per-chat durable outbox retry timer. */
|
|
102
106
|
deliveryRetryTimers = new Map();
|
|
103
107
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
@@ -930,9 +934,9 @@ export class FeishuBot {
|
|
|
930
934
|
return;
|
|
931
935
|
}
|
|
932
936
|
// --- Queue-based sending: if agent is busy, just accumulate ---
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const isBusy =
|
|
937
|
+
// The actual queue owner, not elapsed wall time, decides whether input
|
|
938
|
+
// should steer/queue. Long healthy runs must not lose their busy ownership.
|
|
939
|
+
const isBusy = this.queueRuns.has(chatId);
|
|
936
940
|
if (isBusy) {
|
|
937
941
|
// The agent is mid-run. Instead of only queuing to wait for the current
|
|
938
942
|
// run to finish, try to STEER this message into the active run so it is
|
|
@@ -948,10 +952,12 @@ export class FeishuBot {
|
|
|
948
952
|
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
949
953
|
this.pendingAckMessages.set(chatId, pending);
|
|
950
954
|
const norm = cleanText.replace(/\s+/g, " ").trim();
|
|
955
|
+
let consumedBeforeAck = false;
|
|
951
956
|
const stopWatchingConsumption = this.openclawClient.onSteerConsumed(sessionKeyForSteer, steerWrapped, (consumedText) => {
|
|
952
957
|
const cn = (consumedText || "").replace(/\s+/g, " ").trim();
|
|
953
958
|
if (cn !== norm && !cn.includes(norm))
|
|
954
959
|
return false;
|
|
960
|
+
consumedBeforeAck = true;
|
|
955
961
|
// Only transcript commitment/consumption transfers durable custody.
|
|
956
962
|
// Until this point the local pending trigger remains the fallback for
|
|
957
963
|
// detached dispatch rejection or an admission that never consumed.
|
|
@@ -970,7 +976,7 @@ export class FeishuBot {
|
|
|
970
976
|
});
|
|
971
977
|
const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
|
|
972
978
|
if (steer.status === "steered") {
|
|
973
|
-
if (insertedId > 0) {
|
|
979
|
+
if (insertedId > 0 && !consumedBeforeAck) {
|
|
974
980
|
this.pendingSteerCleanups.set(insertedId, () => {
|
|
975
981
|
stopWatchingConsumption();
|
|
976
982
|
if ("cancelPending" in steer)
|
|
@@ -990,13 +996,18 @@ export class FeishuBot {
|
|
|
990
996
|
if ("cancelPending" in steer)
|
|
991
997
|
steer.cancelPending?.();
|
|
992
998
|
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
999
|
+
// Reconciliation may have finished the old owner during the steer RPC.
|
|
1000
|
+
// Ensure this new, still-pending input is drained even when that owner
|
|
1001
|
+
// exited through an error path. Already-consumed input is never replayed.
|
|
1002
|
+
const owner = this.queueRuns.get(chatId);
|
|
1003
|
+
const drainPending = () => {
|
|
1004
|
+
if (insertedId > 0 && this.store.getPendingTriggerIds(this.config.name, chatId).has(insertedId))
|
|
1005
|
+
return this.processQueue(chatId);
|
|
1006
|
+
};
|
|
1007
|
+
if (owner)
|
|
1008
|
+
void owner.then(drainPending, drainPending).catch(err => console.warn(`[${this.config.name}] pending drain failed:`, err.message));
|
|
1009
|
+
else
|
|
1010
|
+
await drainPending();
|
|
1000
1011
|
return;
|
|
1001
1012
|
}
|
|
1002
1013
|
// --- Not busy, send now (with any accumulated messages) ---
|
|
@@ -1352,7 +1363,7 @@ export class FeishuBot {
|
|
|
1352
1363
|
deliver: false,
|
|
1353
1364
|
// Keep bridge UX responsive; long agent/tool loops should surface a clear failure
|
|
1354
1365
|
// instead of leaving reactions stuck forever.
|
|
1355
|
-
timeoutMs:
|
|
1366
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
1356
1367
|
includeContext: !isNativeCommandTrigger,
|
|
1357
1368
|
includeBridgeAttachmentHint: !isNativeCommandTrigger,
|
|
1358
1369
|
onSendAttempt: () => {
|
|
@@ -1375,6 +1386,14 @@ export class FeishuBot {
|
|
|
1375
1386
|
markSubmittedBatch(`submitted:${runId}`);
|
|
1376
1387
|
},
|
|
1377
1388
|
onProgress: (event) => liveStatus?.progress(event),
|
|
1389
|
+
onWaitPaused: async (notice) => {
|
|
1390
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
1391
|
+
// Yield is a confirmed handoff, not the timeout heuristic. A
|
|
1392
|
+
// recent unrelated reply must not suppress this turn's notice.
|
|
1393
|
+
if (notice.reason === "yield" ? !this.store.hasDeliveredReply(this.config.name, chatId, triggerId)
|
|
1394
|
+
: Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
|
|
1395
|
+
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
|
|
1396
|
+
},
|
|
1378
1397
|
}));
|
|
1379
1398
|
}
|
|
1380
1399
|
catch (mainErr) {
|
|
@@ -1585,6 +1604,43 @@ export class FeishuBot {
|
|
|
1585
1604
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1586
1605
|
}
|
|
1587
1606
|
catch (err) {
|
|
1607
|
+
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
1608
|
+
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
1609
|
+
const state = await this.readSessionStatus(chatId);
|
|
1610
|
+
if (state.running)
|
|
1611
|
+
pause = new SessionWaitPaused(state, true);
|
|
1612
|
+
}
|
|
1613
|
+
if (pause) {
|
|
1614
|
+
const target = this.activeDeliveryTargets.get(chatId);
|
|
1615
|
+
if (target?.triggerId === triggerId && target.runId)
|
|
1616
|
+
this.openclawClient.releasePausedWait?.(target.runId);
|
|
1617
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
|
|
1618
|
+
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
|
|
1619
|
+
// A wait notice is not the final answer and must never claim its key.
|
|
1620
|
+
// Do not mark DONE, retry this input, or abort the running session.
|
|
1621
|
+
for (const id of mergedTriggerIds)
|
|
1622
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1623
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:wait-paused`);
|
|
1624
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
1625
|
+
for (const ack of acks)
|
|
1626
|
+
if (completedTriggerIdSet.has(ack.rowId))
|
|
1627
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1628
|
+
this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
if (err instanceof InactiveRunObservation) {
|
|
1632
|
+
await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
|
|
1633
|
+
if (!liveStatus?.id && lastHuman.messageId)
|
|
1634
|
+
await this.replyMessage(lastHuman.messageId, `ℹ️ ${err.message}`).catch(() => { });
|
|
1635
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
1636
|
+
for (const ack of acks)
|
|
1637
|
+
if (completedTriggerIdSet.has(ack.rowId))
|
|
1638
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1639
|
+
this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
|
|
1640
|
+
// No DONE/delivered mark and no replay of the old input. The new
|
|
1641
|
+
// pending trigger resumes after this queue owner's finally releases.
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1588
1644
|
if (err instanceof UnhealthySessionError) {
|
|
1589
1645
|
// stopForUnhealthySession already warned the user; mark the live
|
|
1590
1646
|
// status as interrupted. The user can send another message to retry
|
|
@@ -1767,13 +1823,13 @@ export class FeishuBot {
|
|
|
1767
1823
|
}
|
|
1768
1824
|
return s;
|
|
1769
1825
|
}
|
|
1770
|
-
buildMarkdownCard(text, model) {
|
|
1826
|
+
buildMarkdownCard(text, model, status) {
|
|
1771
1827
|
const elements = buildFeishuCardElements(text);
|
|
1772
1828
|
if (model?.trim()) {
|
|
1773
1829
|
elements.push({ tag: "hr" });
|
|
1774
1830
|
elements.push({
|
|
1775
1831
|
tag: "markdown",
|
|
1776
|
-
content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
|
|
1832
|
+
content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}${status ? ` · ${this.escapeCardText(status)}` : ""}</font>`,
|
|
1777
1833
|
});
|
|
1778
1834
|
}
|
|
1779
1835
|
return {
|
|
@@ -1816,7 +1872,21 @@ export class FeishuBot {
|
|
|
1816
1872
|
};
|
|
1817
1873
|
await this.patchLiveStatusCard(messageId, view, chatId);
|
|
1818
1874
|
}
|
|
1875
|
+
async readSessionStatus(chatId) {
|
|
1876
|
+
try {
|
|
1877
|
+
return await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
|
|
1878
|
+
}
|
|
1879
|
+
catch {
|
|
1880
|
+
return normalizeSessionRuntimeStatus(undefined);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
async sendWaitPaused(chatId, triggerId, messageId, notice) {
|
|
1884
|
+
const key = triggerId ? `trigger:${triggerId}:wait-paused` : `wait-paused:${notice.snapshot.checkedAt}`;
|
|
1885
|
+
await this.enqueueAndDispatchDelivery(chatId, "wait_paused", key, notice.message, [], messageId, key, this.config.model);
|
|
1886
|
+
}
|
|
1819
1887
|
formatUserVisibleError(err) {
|
|
1888
|
+
if (err instanceof SessionWaitPaused)
|
|
1889
|
+
return err.message;
|
|
1820
1890
|
const raw = err instanceof Error ? err.message : String(err);
|
|
1821
1891
|
let reason = raw.replace(/\s+/g, " ").trim();
|
|
1822
1892
|
if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
|
|
@@ -2039,13 +2109,19 @@ export class FeishuBot {
|
|
|
2039
2109
|
currentMessage: probe,
|
|
2040
2110
|
currentSenderName: senderName,
|
|
2041
2111
|
deliver: false,
|
|
2042
|
-
timeoutMs:
|
|
2112
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
2043
2113
|
includeContext: false,
|
|
2044
2114
|
includeBridgeAttachmentHint: false,
|
|
2045
2115
|
onProgress: (event) => liveStatus?.progress(event),
|
|
2116
|
+
onWaitPaused: async (notice) => {
|
|
2117
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
2118
|
+
await this.sendWaitPaused(chatId, triggerId, lastHumanMessageId, notice);
|
|
2119
|
+
},
|
|
2046
2120
|
}));
|
|
2047
2121
|
}
|
|
2048
2122
|
catch (err) {
|
|
2123
|
+
if (err instanceof SessionWaitPaused)
|
|
2124
|
+
throw err;
|
|
2049
2125
|
// The confirmation round itself failed (timeout/unhealthy). Do not spin:
|
|
2050
2126
|
// deliver the existing (truncated-looking) reply as-is.
|
|
2051
2127
|
console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
|
|
@@ -2134,11 +2210,20 @@ export class FeishuBot {
|
|
|
2134
2210
|
this.cancelDelayedFailure(chatId);
|
|
2135
2211
|
const timer = setTimeout(() => {
|
|
2136
2212
|
this.delayedFailureTimers.delete(chatId);
|
|
2137
|
-
void
|
|
2138
|
-
|
|
2213
|
+
void (async () => {
|
|
2214
|
+
if (isWaitTimeout(text)) {
|
|
2215
|
+
const status = await this.readSessionStatus(chatId);
|
|
2216
|
+
if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) < 90_000)
|
|
2217
|
+
return;
|
|
2218
|
+
if (status.running) {
|
|
2219
|
+
await this.sendWaitPaused(chatId, triggerId, replyToMessageId, new SessionWaitPaused(status, true));
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
await this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`);
|
|
2139
2224
|
if (triggerId)
|
|
2140
2225
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
|
|
2141
|
-
})
|
|
2226
|
+
})()
|
|
2142
2227
|
.catch((err) => {
|
|
2143
2228
|
console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
|
|
2144
2229
|
});
|
|
@@ -2342,23 +2427,28 @@ export class FeishuBot {
|
|
|
2342
2427
|
const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
|
|
2343
2428
|
|| item.sourceType === "verbose_transcript"
|
|
2344
2429
|
|| item.sourceType === "provider_error"
|
|
2345
|
-
|| item.sourceType === "delayed_error"
|
|
2430
|
+
|| item.sourceType === "delayed_error"
|
|
2431
|
+
|| item.sourceType === "wait_paused");
|
|
2346
2432
|
let finalModel;
|
|
2347
2433
|
try {
|
|
2348
2434
|
const meta = JSON.parse(item.deliveryMetaJson || "{}");
|
|
2349
2435
|
finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
|
|
2350
2436
|
}
|
|
2351
2437
|
catch { /* legacy/malformed metadata: send without model footer */ }
|
|
2438
|
+
const needsStatus = Boolean(finalModel) || item.sourceType.startsWith("assistant_visible") || ["provider_error", "delayed_error", "wait_paused"].includes(item.sourceType);
|
|
2439
|
+
const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
|
|
2440
|
+
if (needsStatus && !finalModel)
|
|
2441
|
+
finalModel = this.config.model;
|
|
2352
2442
|
if (shouldReplyToSource) {
|
|
2353
2443
|
try {
|
|
2354
|
-
await this.replyFinalMessage(replyTarget, item.content, finalModel);
|
|
2444
|
+
await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
|
|
2355
2445
|
}
|
|
2356
2446
|
catch {
|
|
2357
|
-
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2447
|
+
await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
|
|
2358
2448
|
}
|
|
2359
2449
|
}
|
|
2360
2450
|
else {
|
|
2361
|
-
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2451
|
+
await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
|
|
2362
2452
|
}
|
|
2363
2453
|
// Rows created by v1.4.3 may still target a live-status card. Preserve
|
|
2364
2454
|
// restart compatibility: send their text as a new message, then
|
|
@@ -2551,6 +2641,7 @@ export class FeishuBot {
|
|
|
2551
2641
|
: undefined;
|
|
2552
2642
|
liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
|
|
2553
2643
|
let reply;
|
|
2644
|
+
let discussionWaitPaused = false;
|
|
2554
2645
|
try {
|
|
2555
2646
|
reply = await this.openclawClient.chatSendWithContext({
|
|
2556
2647
|
sessionKey,
|
|
@@ -2558,20 +2649,36 @@ export class FeishuBot {
|
|
|
2558
2649
|
currentMessage: prompt,
|
|
2559
2650
|
currentSenderName: "Discussion Scheduler",
|
|
2560
2651
|
deliver: false,
|
|
2561
|
-
timeoutMs:
|
|
2652
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
2562
2653
|
emptyFinalAsNoReply: true,
|
|
2563
2654
|
onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
|
|
2655
|
+
onWaitPaused: async (notice) => {
|
|
2656
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
2657
|
+
await this.sendWaitPaused(chatId, 0, undefined, notice);
|
|
2658
|
+
},
|
|
2564
2659
|
});
|
|
2565
2660
|
}
|
|
2566
2661
|
catch (err) {
|
|
2567
|
-
|
|
2568
|
-
|
|
2662
|
+
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
2663
|
+
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
2664
|
+
const status = await this.readSessionStatus(chatId);
|
|
2665
|
+
if (status.running)
|
|
2666
|
+
pause = new SessionWaitPaused(status, true);
|
|
2667
|
+
}
|
|
2668
|
+
if (pause) {
|
|
2669
|
+
discussionWaitPaused = true;
|
|
2670
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
|
|
2671
|
+
await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
|
|
2672
|
+
}
|
|
2673
|
+
else
|
|
2674
|
+
await liveStatus?.fail().catch(() => { });
|
|
2675
|
+
throw pause || err;
|
|
2569
2676
|
}
|
|
2570
2677
|
finally {
|
|
2571
2678
|
// OpenClaw can emit the final assistant session.message shortly after
|
|
2572
2679
|
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
2573
2680
|
// the discussion coordinator already owns user-visible delivery.
|
|
2574
|
-
releaseProactiveMute(120_000);
|
|
2681
|
+
releaseProactiveMute(discussionWaitPaused ? 0 : 120_000);
|
|
2575
2682
|
}
|
|
2576
2683
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
2577
2684
|
const rawVisibleReply = parsedReply.text.trim();
|
|
@@ -2826,21 +2933,25 @@ export class FeishuBot {
|
|
|
2826
2933
|
async deleteMessageById(messageId) {
|
|
2827
2934
|
await this.client.im.v1.message.delete({ path: { message_id: messageId } });
|
|
2828
2935
|
}
|
|
2829
|
-
async replyFinalMessage(messageId, text, model) {
|
|
2936
|
+
async replyFinalMessage(messageId, text, model, status) {
|
|
2830
2937
|
if (!model?.trim())
|
|
2831
2938
|
return this.replyMessage(messageId, text);
|
|
2832
2939
|
this.replyModelFooters.set(messageId, model.trim());
|
|
2940
|
+
if (status)
|
|
2941
|
+
this.replyStatusFooters.set(messageId, status);
|
|
2833
2942
|
try {
|
|
2834
2943
|
return await this.replyMessage(messageId, text);
|
|
2835
2944
|
}
|
|
2836
2945
|
finally {
|
|
2837
2946
|
this.replyModelFooters.delete(messageId);
|
|
2947
|
+
this.replyStatusFooters.delete(messageId);
|
|
2838
2948
|
}
|
|
2839
2949
|
}
|
|
2840
2950
|
async replyMessage(messageId, text) {
|
|
2841
2951
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
2842
2952
|
const model = this.replyModelFooters.get(messageId);
|
|
2843
|
-
const
|
|
2953
|
+
const status = this.replyStatusFooters.get(messageId);
|
|
2954
|
+
const card = this.buildMarkdownCard(text, model, status);
|
|
2844
2955
|
try {
|
|
2845
2956
|
const res = await this.client.im.message.reply({
|
|
2846
2957
|
path: { message_id: messageId },
|
|
@@ -2853,7 +2964,7 @@ export class FeishuBot {
|
|
|
2853
2964
|
}
|
|
2854
2965
|
catch {
|
|
2855
2966
|
// Fallback to plain text if card fails; retain model attribution.
|
|
2856
|
-
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
2967
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
|
|
2857
2968
|
const res = await this.client.im.message.reply({
|
|
2858
2969
|
path: { message_id: messageId },
|
|
2859
2970
|
data: {
|
|
@@ -3185,20 +3296,24 @@ export class FeishuBot {
|
|
|
3185
3296
|
/**
|
|
3186
3297
|
* Send a proactive message to a chat (not a reply).
|
|
3187
3298
|
*/
|
|
3188
|
-
async sendFinalMessage(chatId, text, model) {
|
|
3299
|
+
async sendFinalMessage(chatId, text, model, status) {
|
|
3189
3300
|
if (!model?.trim())
|
|
3190
3301
|
return this.sendMessage(chatId, text);
|
|
3191
3302
|
this.sendModelFooters.set(chatId, model.trim());
|
|
3303
|
+
if (status)
|
|
3304
|
+
this.sendStatusFooters.set(chatId, status);
|
|
3192
3305
|
try {
|
|
3193
3306
|
return await this.sendMessage(chatId, text);
|
|
3194
3307
|
}
|
|
3195
3308
|
finally {
|
|
3196
3309
|
this.sendModelFooters.delete(chatId);
|
|
3310
|
+
this.sendStatusFooters.delete(chatId);
|
|
3197
3311
|
}
|
|
3198
3312
|
}
|
|
3199
3313
|
async sendMessage(chatId, text) {
|
|
3200
3314
|
const model = this.sendModelFooters.get(chatId);
|
|
3201
|
-
const
|
|
3315
|
+
const status = this.sendStatusFooters.get(chatId);
|
|
3316
|
+
const card = this.buildMarkdownCard(text, model, status);
|
|
3202
3317
|
try {
|
|
3203
3318
|
const res = await this.client.im.message.create({
|
|
3204
3319
|
params: { receive_id_type: "chat_id" },
|
|
@@ -3217,7 +3332,7 @@ export class FeishuBot {
|
|
|
3217
3332
|
this.markCurrentBotUnavailable(chatId, err);
|
|
3218
3333
|
// Fallback to plain text; retain model attribution.
|
|
3219
3334
|
try {
|
|
3220
|
-
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
3335
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
|
|
3221
3336
|
const res = await this.client.im.message.create({
|
|
3222
3337
|
params: { receive_id_type: "chat_id" },
|
|
3223
3338
|
data: {
|
package/dist/live-status.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ 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;
|
|
53
55
|
};
|
|
54
56
|
export declare class LiveStatusController {
|
|
55
57
|
private readonly callbacks;
|
|
@@ -61,8 +63,11 @@ export declare class LiveStatusController {
|
|
|
61
63
|
private startedAt;
|
|
62
64
|
private createTimer?;
|
|
63
65
|
private tickTimer?;
|
|
66
|
+
private refreshBudgetTimer?;
|
|
64
67
|
private createPromise?;
|
|
65
68
|
private finalized;
|
|
69
|
+
private waitingForResult;
|
|
70
|
+
private updatesFrozen;
|
|
66
71
|
private disabled;
|
|
67
72
|
/** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
|
|
68
73
|
* patch code=2200 "Internal Error") must NOT permanently disable the card:
|
|
@@ -79,6 +84,12 @@ export declare class LiveStatusController {
|
|
|
79
84
|
get id(): string | undefined;
|
|
80
85
|
start(initialDetail?: string): void;
|
|
81
86
|
progress(event: ProgressEvent | string): Promise<void>;
|
|
87
|
+
/** Freeze recurring/progress edits, not the original result observer.
|
|
88
|
+
* A single final/error summary remains allowed when the real outcome arrives. */
|
|
89
|
+
showWaitingForResult(detail: string): Promise<void>;
|
|
90
|
+
/** Release only local observation of a now-idle session, without claiming
|
|
91
|
+
* the previous task succeeded or was aborted. Normal progress is unchanged. */
|
|
92
|
+
pauseForUnconfirmedResult(detail: string, title?: string): Promise<void>;
|
|
82
93
|
complete(): Promise<void>;
|
|
83
94
|
/**
|
|
84
95
|
* Freeze any successful terminal state (text, attachment-only, or NO_REPLY)
|
package/dist/live-status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
1
2
|
const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
|
|
2
3
|
const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
|
|
3
4
|
// How many recent activity lines to keep in the card content area.
|
|
@@ -22,8 +23,11 @@ export class LiveStatusController {
|
|
|
22
23
|
startedAt = Date.now();
|
|
23
24
|
createTimer;
|
|
24
25
|
tickTimer;
|
|
26
|
+
refreshBudgetTimer;
|
|
25
27
|
createPromise;
|
|
26
28
|
finalized = false;
|
|
29
|
+
waitingForResult = false;
|
|
30
|
+
updatesFrozen = false;
|
|
27
31
|
disabled = false;
|
|
28
32
|
/** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
|
|
29
33
|
* patch code=2200 "Internal Error") must NOT permanently disable the card:
|
|
@@ -42,9 +46,15 @@ export class LiveStatusController {
|
|
|
42
46
|
}
|
|
43
47
|
get id() { return this.messageId; }
|
|
44
48
|
start(initialDetail) {
|
|
45
|
-
if (this.disabled || this.finalized || this.createTimer || this.messageId)
|
|
49
|
+
if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
|
|
46
50
|
return;
|
|
47
51
|
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?.();
|
|
48
58
|
if (initialDetail && initialDetail.trim()) {
|
|
49
59
|
this.pushLine("lifecycle", initialDetail.trim());
|
|
50
60
|
}
|
|
@@ -56,6 +66,12 @@ export class LiveStatusController {
|
|
|
56
66
|
async progress(event) {
|
|
57
67
|
if (this.disabled || this.finalized)
|
|
58
68
|
return;
|
|
69
|
+
if (this.updatesFrozen) {
|
|
70
|
+
// Keep only the cheap local counter for the eventual one-time summary.
|
|
71
|
+
if (typeof event !== "string" && event.kind === "tool" && event.phase === "start")
|
|
72
|
+
this.toolCallCount++;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
59
75
|
// Card patch has no 20-edit cap (verified against Feishu im.message.patch),
|
|
60
76
|
// so we can show a small rolling window of recent activity: tool start, tool
|
|
61
77
|
// end, and intermediate assistant text each count as one line. We ignore
|
|
@@ -96,9 +112,41 @@ export class LiveStatusController {
|
|
|
96
112
|
else {
|
|
97
113
|
return; // lifecycle ticks: ignore (footer timer already advances)
|
|
98
114
|
}
|
|
115
|
+
this.waitingForResult = false;
|
|
99
116
|
await this.ensureCreatedNow();
|
|
100
117
|
await this.safeEdit(this.buildView());
|
|
101
118
|
}
|
|
119
|
+
/** Freeze recurring/progress edits, not the original result observer.
|
|
120
|
+
* A single final/error summary remains allowed when the real outcome arrives. */
|
|
121
|
+
async showWaitingForResult(detail) {
|
|
122
|
+
if (this.disabled || this.finalized || this.updatesFrozen)
|
|
123
|
+
return;
|
|
124
|
+
this.updatesFrozen = true;
|
|
125
|
+
this.waitingForResult = true;
|
|
126
|
+
this.pushLine("lifecycle", detail);
|
|
127
|
+
this.stopTimers();
|
|
128
|
+
if (this.createPromise)
|
|
129
|
+
await this.createPromise.catch(() => { });
|
|
130
|
+
this.stopTimers();
|
|
131
|
+
if (this.messageId && !this.disabled)
|
|
132
|
+
await this.safeEditFinal(this.buildView());
|
|
133
|
+
}
|
|
134
|
+
/** Release only local observation of a now-idle session, without claiming
|
|
135
|
+
* the previous task succeeded or was aborted. Normal progress is unchanged. */
|
|
136
|
+
async pauseForUnconfirmedResult(detail, title) {
|
|
137
|
+
if (this.finalized)
|
|
138
|
+
return;
|
|
139
|
+
this.pushLine("lifecycle", detail);
|
|
140
|
+
this.finalized = true;
|
|
141
|
+
this.stopTimers();
|
|
142
|
+
if (this.createPromise)
|
|
143
|
+
await this.createPromise.catch(() => { });
|
|
144
|
+
if (!this.messageId || this.disabled)
|
|
145
|
+
return;
|
|
146
|
+
const view = this.buildView();
|
|
147
|
+
view.title = title || (this.opts.locale === "en" ? `${this.opts.botName} result unconfirmed` : `${this.opts.botName} 结果未确认`);
|
|
148
|
+
await this.safeEditFinal(view);
|
|
149
|
+
}
|
|
102
150
|
async complete() {
|
|
103
151
|
this.state = "done";
|
|
104
152
|
this.finalized = true;
|
|
@@ -205,6 +253,10 @@ export class LiveStatusController {
|
|
|
205
253
|
this.lines = this.lines.slice(this.lines.length - limit);
|
|
206
254
|
}
|
|
207
255
|
stopTimers() {
|
|
256
|
+
if (this.refreshBudgetTimer) {
|
|
257
|
+
clearTimeout(this.refreshBudgetTimer);
|
|
258
|
+
this.refreshBudgetTimer = undefined;
|
|
259
|
+
}
|
|
208
260
|
if (this.createTimer) {
|
|
209
261
|
clearTimeout(this.createTimer);
|
|
210
262
|
this.createTimer = undefined;
|
|
@@ -244,11 +296,11 @@ export class LiveStatusController {
|
|
|
244
296
|
}
|
|
245
297
|
/** Refresh the elapsed-time footer periodically even with no new activity. */
|
|
246
298
|
startTicker() {
|
|
247
|
-
if (this.tickTimer || this.disabled || this.finalized)
|
|
299
|
+
if (this.tickTimer || this.disabled || this.finalized || this.updatesFrozen)
|
|
248
300
|
return;
|
|
249
301
|
const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
|
|
250
302
|
this.tickTimer = setInterval(() => {
|
|
251
|
-
if (this.finalized || this.disabled || !this.messageId)
|
|
303
|
+
if (this.finalized || this.disabled || this.updatesFrozen || !this.messageId)
|
|
252
304
|
return;
|
|
253
305
|
void this.safeEdit(this.buildView());
|
|
254
306
|
}, tickMs);
|
|
@@ -257,7 +309,7 @@ export class LiveStatusController {
|
|
|
257
309
|
async safeEdit(view, force = false) {
|
|
258
310
|
if (!this.messageId || this.disabled)
|
|
259
311
|
return false;
|
|
260
|
-
if (!force && this.finalized)
|
|
312
|
+
if (!force && (this.finalized || this.updatesFrozen))
|
|
261
313
|
return false;
|
|
262
314
|
const sig = this.signature(view);
|
|
263
315
|
if (!force && sig === this.lastSentSignature)
|
|
@@ -338,6 +390,8 @@ export class LiveStatusController {
|
|
|
338
390
|
title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
|
|
339
391
|
else if (this.state === "failed")
|
|
340
392
|
title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
393
|
+
else if (this.waitingForResult)
|
|
394
|
+
title = en ? `${this.opts.botName} waiting (card frozen)` : `${this.opts.botName} 等待后续结果(已停止刷新)`;
|
|
341
395
|
else
|
|
342
396
|
title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
|
|
343
397
|
// On a clean finish (done / NO_REPLY) show only the compact summary. On a
|