openclaw-lark-multi-agent 1.4.7 → 1.4.9
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 +35 -16
- package/README.zh-CN.md +17 -1
- package/dist/cli.js +4 -4
- package/dist/feishu-bot.d.ts +4 -0
- package/dist/feishu-bot.js +151 -67
- package/dist/live-status.d.ts +11 -0
- package/dist/live-status.js +58 -4
- package/dist/openclaw-client.d.ts +26 -9
- package/dist/openclaw-client.js +280 -62
- package/dist/session-status.d.ts +18 -0
- package/dist/session-status.js +39 -0
- package/package.json +4 -4
- package/plugins/lma-steer/README.md +28 -21
- package/plugins/lma-steer/openclaw.plugin.json +57 -2
- package/plugins/lma-steer/package.json +4 -2
package/README.md
CHANGED
|
@@ -101,18 +101,17 @@ lma install-windows-service
|
|
|
101
101
|
|
|
102
102
|
### Real-time steering
|
|
103
103
|
|
|
104
|
-
When the agent is mid-run and a new message arrives, the bridge
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
matching `session.message` proves transcript
|
|
108
|
-
then switches Feishu from Typing to Get.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
current LMA releases do not call its private Gateway method.
|
|
104
|
+
When the agent is mid-run and a new message arrives, the bridge calls the
|
|
105
|
+
bundled `lma-steer` plugin. The plugin resolves the active embedded run and only
|
|
106
|
+
returns `steered` after queueing the message into that run. LMA then retains its
|
|
107
|
+
durable pending trigger until a matching `session.message` proves transcript
|
|
108
|
+
commitment/consumption, and only then switches Feishu from Typing to Get.
|
|
109
|
+
|
|
110
|
+
OpenClaw's public `chat.send { queueMode: "steer" }` returns `started` even when
|
|
111
|
+
a message falls through to a later ordinary run, so LMA does not use that
|
|
112
|
+
ambiguous response as proof of realtime insertion. If the plugin is unavailable,
|
|
113
|
+
there is no active run, or injection is rejected, the message safely stays in
|
|
114
|
+
the normal queue and is never falsely shown as inserted.
|
|
116
115
|
|
|
117
116
|
Useful CLI commands:
|
|
118
117
|
|
|
@@ -122,7 +121,7 @@ lma doctor
|
|
|
122
121
|
lma start [config]
|
|
123
122
|
lma init [--state-dir DIR] [--force]
|
|
124
123
|
lma install-systemd [--user|--system] [--state-dir DIR]
|
|
125
|
-
lma install-steer-plugin [--no-force] #
|
|
124
|
+
lma install-steer-plugin [--no-force] # required for realtime mid-run insertion
|
|
126
125
|
```
|
|
127
126
|
|
|
128
127
|
## Quick start from source
|
|
@@ -464,9 +463,11 @@ when that list is non-empty. Exact refs and `provider/*` wildcards are accepted.
|
|
|
464
463
|
LMA now surfaces a model-policy rejection instead of logging a false “model
|
|
465
464
|
ensured” success.
|
|
466
465
|
|
|
467
|
-
Mid-run messages use the
|
|
468
|
-
|
|
469
|
-
|
|
466
|
+
Mid-run messages use the bundled `lma-steer` plugin because public
|
|
467
|
+
`chat.send { queueMode: "steer" }` cannot distinguish true in-run insertion from
|
|
468
|
+
a later ordinary run. LMA keeps the durable trigger until `session.message`
|
|
469
|
+
confirms consumption; this is also the signal for the visible Typing-to-Get
|
|
470
|
+
transition. Without the plugin, messages remain safely queued.
|
|
470
471
|
|
|
471
472
|
## Live status
|
|
472
473
|
|
|
@@ -589,3 +590,21 @@ If a secret is ever committed, remove it from the current tree and rewrite git h
|
|
|
589
590
|
## License
|
|
590
591
|
|
|
591
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
|
@@ -414,7 +414,7 @@ LMA 以 Gateway protocol 4 为基线,并使用 `openclaw@2026.8.2` 构建和
|
|
|
414
414
|
|
|
415
415
|
新版把模型元数据与模型覆盖允许列表分开。如果 `agents.defaults.modelPolicy.allow` 非空,LMA 配置中的每个模型都必须被该列表的精确模型引用或 `provider/*` 通配符允许。遇到拒绝时,LMA 会直接报告 model policy 错误,不再错误记录“模型已确保”。
|
|
416
416
|
|
|
417
|
-
|
|
417
|
+
执行中插入的消息使用内置 `lma-steer` 插件。插件会解析当前 active embedded run,只有消息确实排入该 run 时才返回 `steered`。LMA 仍会保留本地 pending trigger,直到匹配的 `session.message` 确认消息已写入转录并被消费后才清除,同时把飞书标记从 Typing 切换为 Get。公开的 `chat.send { queueMode: "steer" }` 即使退化为稍后的普通 run 也只返回 `started`,因此不再把它当作实时插入成功的证据。插件不可用、没有 active run 或拒绝插入时,消息会安全保留在普通队列中,不会伪报已插入。
|
|
418
418
|
|
|
419
419
|
## Live Status 与最终回复
|
|
420
420
|
|
|
@@ -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/cli.js
CHANGED
|
@@ -206,12 +206,12 @@ function runCapture(cmd, args) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
/**
|
|
209
|
-
* Install the
|
|
210
|
-
*
|
|
211
|
-
* the
|
|
209
|
+
* Install the bundled lma-steer plugin used for observable realtime mid-run
|
|
210
|
+
* insertion. LMA remains functional without it, but busy messages queue until
|
|
211
|
+
* the active run completes.
|
|
212
212
|
*/
|
|
213
213
|
function cmdInstallSteerPlugin(args) {
|
|
214
|
-
console.warn("Note:
|
|
214
|
+
console.warn("Note: lma-steer is required for confirmed realtime mid-run insertion; without it, busy messages remain safely queued.");
|
|
215
215
|
// Default to --force so reinstalling an updated bundled plugin overwrites the
|
|
216
216
|
// previously installed copy. Pass --no-force to keep an existing install.
|
|
217
217
|
const noForce = hasFlag(args, "--no-force");
|
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.
|
|
@@ -968,63 +974,40 @@ export class FeishuBot {
|
|
|
968
974
|
this.pendingAckMessages.set(chatId, acks);
|
|
969
975
|
return true;
|
|
970
976
|
});
|
|
971
|
-
const previousActiveTarget = this.activeDeliveryTargets.get(chatId);
|
|
972
|
-
// Install the new Feishu delivery target before admission. OpenClaw may
|
|
973
|
-
// race from "old run still active" to "dispatch a new run" while
|
|
974
|
-
// handling queueMode=steer, and that new run can emit events before the
|
|
975
|
-
// RPC response reaches LMA. Either outcome now routes to this message.
|
|
976
|
-
const releaseSteerDeliveryTarget = this.setActiveDeliveryTarget(chatId, insertedId, messageId);
|
|
977
|
-
const provisionalTarget = this.activeDeliveryTargets.get(chatId);
|
|
978
977
|
const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
|
|
979
978
|
if (steer.status === "steered") {
|
|
980
|
-
if (insertedId > 0) {
|
|
979
|
+
if (insertedId > 0 && !consumedBeforeAck) {
|
|
981
980
|
this.pendingSteerCleanups.set(insertedId, () => {
|
|
982
981
|
stopWatchingConsumption();
|
|
983
982
|
if ("cancelPending" in steer)
|
|
984
983
|
steer.cancelPending?.();
|
|
985
984
|
});
|
|
986
985
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
992
|
-
// A steer admission may become an ordinary new run. Keep its run-bound
|
|
993
|
-
// reply target long enough for a real tool-heavy run; stale targets are
|
|
994
|
-
// harmless because later runIds no longer match.
|
|
995
|
-
releaseSteerDeliveryTarget(30 * 60_000);
|
|
996
|
-
// RPC acknowledgment is provisional. Keep the durable pending trigger
|
|
997
|
-
// until session.message confirms consumption; otherwise the normal
|
|
998
|
-
// queue drains it after the current run.
|
|
999
|
-
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, steer accepted (awaiting transcript consumption): "${cleanText.substring(0, 50)}..."`);
|
|
986
|
+
// The plugin confirmed queueing into the existing embedded run. Keep
|
|
987
|
+
// that run's original delivery target and retain the durable trigger
|
|
988
|
+
// until session.message proves this specific input was consumed.
|
|
989
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, plugin steer accepted (awaiting transcript consumption): "${cleanText.substring(0, 50)}..."`);
|
|
1000
990
|
return;
|
|
1001
991
|
}
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
992
|
+
// Plugin missing/no active run/rejected: keep the durable trigger for
|
|
993
|
+
// normal queue drain. Never claim that ambiguous native admission was an
|
|
994
|
+
// in-run insertion.
|
|
1004
995
|
stopWatchingConsumption();
|
|
1005
996
|
if ("cancelPending" in steer)
|
|
1006
997
|
steer.cancelPending?.();
|
|
1007
|
-
const currentTarget = this.activeDeliveryTargets.get(chatId);
|
|
1008
|
-
if (currentTarget && currentTarget.token === provisionalTarget?.token) {
|
|
1009
|
-
if (currentTarget.timer)
|
|
1010
|
-
clearTimeout(currentTarget.timer);
|
|
1011
|
-
this.activeDeliveryTargets.delete(chatId);
|
|
1012
|
-
if (previousActiveTarget) {
|
|
1013
|
-
const releaseRestoredTarget = this.setActiveDeliveryTarget(chatId, previousActiveTarget.triggerId, previousActiveTarget.messageId);
|
|
1014
|
-
const restored = this.activeDeliveryTargets.get(chatId);
|
|
1015
|
-
if (restored)
|
|
1016
|
-
restored.liveStatus = previousActiveTarget.liveStatus;
|
|
1017
|
-
releaseRestoredTarget();
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
998
|
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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();
|
|
1028
1011
|
return;
|
|
1029
1012
|
}
|
|
1030
1013
|
// --- Not busy, send now (with any accumulated messages) ---
|
|
@@ -1380,7 +1363,7 @@ export class FeishuBot {
|
|
|
1380
1363
|
deliver: false,
|
|
1381
1364
|
// Keep bridge UX responsive; long agent/tool loops should surface a clear failure
|
|
1382
1365
|
// instead of leaving reactions stuck forever.
|
|
1383
|
-
timeoutMs:
|
|
1366
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
1384
1367
|
includeContext: !isNativeCommandTrigger,
|
|
1385
1368
|
includeBridgeAttachmentHint: !isNativeCommandTrigger,
|
|
1386
1369
|
onSendAttempt: () => {
|
|
@@ -1403,6 +1386,11 @@ export class FeishuBot {
|
|
|
1403
1386
|
markSubmittedBatch(`submitted:${runId}`);
|
|
1404
1387
|
},
|
|
1405
1388
|
onProgress: (event) => liveStatus?.progress(event),
|
|
1389
|
+
onWaitPaused: async (notice) => {
|
|
1390
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
1391
|
+
if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
|
|
1392
|
+
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
|
|
1393
|
+
},
|
|
1406
1394
|
}));
|
|
1407
1395
|
}
|
|
1408
1396
|
catch (mainErr) {
|
|
@@ -1613,6 +1601,43 @@ export class FeishuBot {
|
|
|
1613
1601
|
this.pendingAckMessages.set(chatId, remainingAcks);
|
|
1614
1602
|
}
|
|
1615
1603
|
catch (err) {
|
|
1604
|
+
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
1605
|
+
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
1606
|
+
const state = await this.readSessionStatus(chatId);
|
|
1607
|
+
if (state.running)
|
|
1608
|
+
pause = new SessionWaitPaused(state, true);
|
|
1609
|
+
}
|
|
1610
|
+
if (pause) {
|
|
1611
|
+
const target = this.activeDeliveryTargets.get(chatId);
|
|
1612
|
+
if (target?.triggerId === triggerId && target.runId)
|
|
1613
|
+
this.openclawClient.releasePausedWait?.(target.runId);
|
|
1614
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
|
|
1615
|
+
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
|
|
1616
|
+
// A wait notice is not the final answer and must never claim its key.
|
|
1617
|
+
// Do not mark DONE, retry this input, or abort the running session.
|
|
1618
|
+
for (const id of mergedTriggerIds)
|
|
1619
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1620
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:wait-paused`);
|
|
1621
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
1622
|
+
for (const ack of acks)
|
|
1623
|
+
if (completedTriggerIdSet.has(ack.rowId))
|
|
1624
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1625
|
+
this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
|
|
1626
|
+
break;
|
|
1627
|
+
}
|
|
1628
|
+
if (err instanceof InactiveRunObservation) {
|
|
1629
|
+
await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
|
|
1630
|
+
if (!liveStatus?.id && lastHuman.messageId)
|
|
1631
|
+
await this.replyMessage(lastHuman.messageId, `ℹ️ ${err.message}`).catch(() => { });
|
|
1632
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
1633
|
+
for (const ack of acks)
|
|
1634
|
+
if (completedTriggerIdSet.has(ack.rowId))
|
|
1635
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
1636
|
+
this.pendingAckMessages.set(chatId, acks.filter(ack => !completedTriggerIdSet.has(ack.rowId)));
|
|
1637
|
+
// No DONE/delivered mark and no replay of the old input. The new
|
|
1638
|
+
// pending trigger resumes after this queue owner's finally releases.
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1616
1641
|
if (err instanceof UnhealthySessionError) {
|
|
1617
1642
|
// stopForUnhealthySession already warned the user; mark the live
|
|
1618
1643
|
// status as interrupted. The user can send another message to retry
|
|
@@ -1795,13 +1820,13 @@ export class FeishuBot {
|
|
|
1795
1820
|
}
|
|
1796
1821
|
return s;
|
|
1797
1822
|
}
|
|
1798
|
-
buildMarkdownCard(text, model) {
|
|
1823
|
+
buildMarkdownCard(text, model, status) {
|
|
1799
1824
|
const elements = buildFeishuCardElements(text);
|
|
1800
1825
|
if (model?.trim()) {
|
|
1801
1826
|
elements.push({ tag: "hr" });
|
|
1802
1827
|
elements.push({
|
|
1803
1828
|
tag: "markdown",
|
|
1804
|
-
content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
|
|
1829
|
+
content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}${status ? ` · ${this.escapeCardText(status)}` : ""}</font>`,
|
|
1805
1830
|
});
|
|
1806
1831
|
}
|
|
1807
1832
|
return {
|
|
@@ -1844,7 +1869,21 @@ export class FeishuBot {
|
|
|
1844
1869
|
};
|
|
1845
1870
|
await this.patchLiveStatusCard(messageId, view, chatId);
|
|
1846
1871
|
}
|
|
1872
|
+
async readSessionStatus(chatId) {
|
|
1873
|
+
try {
|
|
1874
|
+
return await this.openclawClient.getSessionRuntimeStatus(this.getSessionKey(chatId));
|
|
1875
|
+
}
|
|
1876
|
+
catch {
|
|
1877
|
+
return normalizeSessionRuntimeStatus(undefined);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
async sendWaitPaused(chatId, triggerId, messageId, notice) {
|
|
1881
|
+
const key = triggerId ? `trigger:${triggerId}:wait-paused` : `wait-paused:${notice.snapshot.checkedAt}`;
|
|
1882
|
+
await this.enqueueAndDispatchDelivery(chatId, "wait_paused", key, notice.message, [], messageId, key, this.config.model);
|
|
1883
|
+
}
|
|
1847
1884
|
formatUserVisibleError(err) {
|
|
1885
|
+
if (err instanceof SessionWaitPaused)
|
|
1886
|
+
return err.message;
|
|
1848
1887
|
const raw = err instanceof Error ? err.message : String(err);
|
|
1849
1888
|
let reason = raw.replace(/\s+/g, " ").trim();
|
|
1850
1889
|
if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
|
|
@@ -2067,13 +2106,19 @@ export class FeishuBot {
|
|
|
2067
2106
|
currentMessage: probe,
|
|
2068
2107
|
currentSenderName: senderName,
|
|
2069
2108
|
deliver: false,
|
|
2070
|
-
timeoutMs:
|
|
2109
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
2071
2110
|
includeContext: false,
|
|
2072
2111
|
includeBridgeAttachmentHint: false,
|
|
2073
2112
|
onProgress: (event) => liveStatus?.progress(event),
|
|
2113
|
+
onWaitPaused: async (notice) => {
|
|
2114
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
2115
|
+
await this.sendWaitPaused(chatId, triggerId, lastHumanMessageId, notice);
|
|
2116
|
+
},
|
|
2074
2117
|
}));
|
|
2075
2118
|
}
|
|
2076
2119
|
catch (err) {
|
|
2120
|
+
if (err instanceof SessionWaitPaused)
|
|
2121
|
+
throw err;
|
|
2077
2122
|
// The confirmation round itself failed (timeout/unhealthy). Do not spin:
|
|
2078
2123
|
// deliver the existing (truncated-looking) reply as-is.
|
|
2079
2124
|
console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
|
|
@@ -2162,11 +2207,20 @@ export class FeishuBot {
|
|
|
2162
2207
|
this.cancelDelayedFailure(chatId);
|
|
2163
2208
|
const timer = setTimeout(() => {
|
|
2164
2209
|
this.delayedFailureTimers.delete(chatId);
|
|
2165
|
-
void
|
|
2166
|
-
|
|
2210
|
+
void (async () => {
|
|
2211
|
+
if (isWaitTimeout(text)) {
|
|
2212
|
+
const status = await this.readSessionStatus(chatId);
|
|
2213
|
+
if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) < 90_000)
|
|
2214
|
+
return;
|
|
2215
|
+
if (status.running) {
|
|
2216
|
+
await this.sendWaitPaused(chatId, triggerId, replyToMessageId, new SessionWaitPaused(status, true));
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
await this.enqueueAndDispatchDelivery(chatId, "delayed_error", `trigger:${triggerId}:delayed-error`, text, [], replyToMessageId, `trigger:${triggerId}:delayed-error`);
|
|
2167
2221
|
if (triggerId)
|
|
2168
2222
|
this.store.markDeliveredReply(this.config.name, chatId, triggerId, replyToMessageId);
|
|
2169
|
-
})
|
|
2223
|
+
})()
|
|
2170
2224
|
.catch((err) => {
|
|
2171
2225
|
console.warn(`[${this.config.name}] delayed failure delivery failed:`, err.message);
|
|
2172
2226
|
});
|
|
@@ -2370,23 +2424,28 @@ export class FeishuBot {
|
|
|
2370
2424
|
const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
|
|
2371
2425
|
|| item.sourceType === "verbose_transcript"
|
|
2372
2426
|
|| item.sourceType === "provider_error"
|
|
2373
|
-
|| item.sourceType === "delayed_error"
|
|
2427
|
+
|| item.sourceType === "delayed_error"
|
|
2428
|
+
|| item.sourceType === "wait_paused");
|
|
2374
2429
|
let finalModel;
|
|
2375
2430
|
try {
|
|
2376
2431
|
const meta = JSON.parse(item.deliveryMetaJson || "{}");
|
|
2377
2432
|
finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
|
|
2378
2433
|
}
|
|
2379
2434
|
catch { /* legacy/malformed metadata: send without model footer */ }
|
|
2435
|
+
const needsStatus = Boolean(finalModel) || item.sourceType.startsWith("assistant_visible") || ["provider_error", "delayed_error", "wait_paused"].includes(item.sourceType);
|
|
2436
|
+
const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
|
|
2437
|
+
if (needsStatus && !finalModel)
|
|
2438
|
+
finalModel = this.config.model;
|
|
2380
2439
|
if (shouldReplyToSource) {
|
|
2381
2440
|
try {
|
|
2382
|
-
await this.replyFinalMessage(replyTarget, item.content, finalModel);
|
|
2441
|
+
await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
|
|
2383
2442
|
}
|
|
2384
2443
|
catch {
|
|
2385
|
-
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2444
|
+
await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
|
|
2386
2445
|
}
|
|
2387
2446
|
}
|
|
2388
2447
|
else {
|
|
2389
|
-
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2448
|
+
await this.sendFinalMessage(chatId, item.content, finalModel, finalStatus);
|
|
2390
2449
|
}
|
|
2391
2450
|
// Rows created by v1.4.3 may still target a live-status card. Preserve
|
|
2392
2451
|
// restart compatibility: send their text as a new message, then
|
|
@@ -2579,6 +2638,7 @@ export class FeishuBot {
|
|
|
2579
2638
|
: undefined;
|
|
2580
2639
|
liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
|
|
2581
2640
|
let reply;
|
|
2641
|
+
let discussionWaitPaused = false;
|
|
2582
2642
|
try {
|
|
2583
2643
|
reply = await this.openclawClient.chatSendWithContext({
|
|
2584
2644
|
sessionKey,
|
|
@@ -2586,20 +2646,36 @@ export class FeishuBot {
|
|
|
2586
2646
|
currentMessage: prompt,
|
|
2587
2647
|
currentSenderName: "Discussion Scheduler",
|
|
2588
2648
|
deliver: false,
|
|
2589
|
-
timeoutMs:
|
|
2649
|
+
timeoutMs: FOREGROUND_WAIT_MS,
|
|
2590
2650
|
emptyFinalAsNoReply: true,
|
|
2591
2651
|
onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
|
|
2652
|
+
onWaitPaused: async (notice) => {
|
|
2653
|
+
await liveStatus?.showWaitingForResult(notice.message);
|
|
2654
|
+
await this.sendWaitPaused(chatId, 0, undefined, notice);
|
|
2655
|
+
},
|
|
2592
2656
|
});
|
|
2593
2657
|
}
|
|
2594
2658
|
catch (err) {
|
|
2595
|
-
|
|
2596
|
-
|
|
2659
|
+
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
2660
|
+
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
2661
|
+
const status = await this.readSessionStatus(chatId);
|
|
2662
|
+
if (status.running)
|
|
2663
|
+
pause = new SessionWaitPaused(status, true);
|
|
2664
|
+
}
|
|
2665
|
+
if (pause) {
|
|
2666
|
+
discussionWaitPaused = true;
|
|
2667
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`).catch(() => { });
|
|
2668
|
+
await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
|
|
2669
|
+
}
|
|
2670
|
+
else
|
|
2671
|
+
await liveStatus?.fail().catch(() => { });
|
|
2672
|
+
throw pause || err;
|
|
2597
2673
|
}
|
|
2598
2674
|
finally {
|
|
2599
2675
|
// OpenClaw can emit the final assistant session.message shortly after
|
|
2600
2676
|
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
2601
2677
|
// the discussion coordinator already owns user-visible delivery.
|
|
2602
|
-
releaseProactiveMute(120_000);
|
|
2678
|
+
releaseProactiveMute(discussionWaitPaused ? 0 : 120_000);
|
|
2603
2679
|
}
|
|
2604
2680
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
2605
2681
|
const rawVisibleReply = parsedReply.text.trim();
|
|
@@ -2854,21 +2930,25 @@ export class FeishuBot {
|
|
|
2854
2930
|
async deleteMessageById(messageId) {
|
|
2855
2931
|
await this.client.im.v1.message.delete({ path: { message_id: messageId } });
|
|
2856
2932
|
}
|
|
2857
|
-
async replyFinalMessage(messageId, text, model) {
|
|
2933
|
+
async replyFinalMessage(messageId, text, model, status) {
|
|
2858
2934
|
if (!model?.trim())
|
|
2859
2935
|
return this.replyMessage(messageId, text);
|
|
2860
2936
|
this.replyModelFooters.set(messageId, model.trim());
|
|
2937
|
+
if (status)
|
|
2938
|
+
this.replyStatusFooters.set(messageId, status);
|
|
2861
2939
|
try {
|
|
2862
2940
|
return await this.replyMessage(messageId, text);
|
|
2863
2941
|
}
|
|
2864
2942
|
finally {
|
|
2865
2943
|
this.replyModelFooters.delete(messageId);
|
|
2944
|
+
this.replyStatusFooters.delete(messageId);
|
|
2866
2945
|
}
|
|
2867
2946
|
}
|
|
2868
2947
|
async replyMessage(messageId, text) {
|
|
2869
2948
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
2870
2949
|
const model = this.replyModelFooters.get(messageId);
|
|
2871
|
-
const
|
|
2950
|
+
const status = this.replyStatusFooters.get(messageId);
|
|
2951
|
+
const card = this.buildMarkdownCard(text, model, status);
|
|
2872
2952
|
try {
|
|
2873
2953
|
const res = await this.client.im.message.reply({
|
|
2874
2954
|
path: { message_id: messageId },
|
|
@@ -2881,7 +2961,7 @@ export class FeishuBot {
|
|
|
2881
2961
|
}
|
|
2882
2962
|
catch {
|
|
2883
2963
|
// Fallback to plain text if card fails; retain model attribution.
|
|
2884
|
-
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
2964
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
|
|
2885
2965
|
const res = await this.client.im.message.reply({
|
|
2886
2966
|
path: { message_id: messageId },
|
|
2887
2967
|
data: {
|
|
@@ -3213,20 +3293,24 @@ export class FeishuBot {
|
|
|
3213
3293
|
/**
|
|
3214
3294
|
* Send a proactive message to a chat (not a reply).
|
|
3215
3295
|
*/
|
|
3216
|
-
async sendFinalMessage(chatId, text, model) {
|
|
3296
|
+
async sendFinalMessage(chatId, text, model, status) {
|
|
3217
3297
|
if (!model?.trim())
|
|
3218
3298
|
return this.sendMessage(chatId, text);
|
|
3219
3299
|
this.sendModelFooters.set(chatId, model.trim());
|
|
3300
|
+
if (status)
|
|
3301
|
+
this.sendStatusFooters.set(chatId, status);
|
|
3220
3302
|
try {
|
|
3221
3303
|
return await this.sendMessage(chatId, text);
|
|
3222
3304
|
}
|
|
3223
3305
|
finally {
|
|
3224
3306
|
this.sendModelFooters.delete(chatId);
|
|
3307
|
+
this.sendStatusFooters.delete(chatId);
|
|
3225
3308
|
}
|
|
3226
3309
|
}
|
|
3227
3310
|
async sendMessage(chatId, text) {
|
|
3228
3311
|
const model = this.sendModelFooters.get(chatId);
|
|
3229
|
-
const
|
|
3312
|
+
const status = this.sendStatusFooters.get(chatId);
|
|
3313
|
+
const card = this.buildMarkdownCard(text, model, status);
|
|
3230
3314
|
try {
|
|
3231
3315
|
const res = await this.client.im.message.create({
|
|
3232
3316
|
params: { receive_id_type: "chat_id" },
|
|
@@ -3245,7 +3329,7 @@ export class FeishuBot {
|
|
|
3245
3329
|
this.markCurrentBotUnavailable(chatId, err);
|
|
3246
3330
|
// Fallback to plain text; retain model attribution.
|
|
3247
3331
|
try {
|
|
3248
|
-
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
3332
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
|
|
3249
3333
|
const res = await this.client.im.message.create({
|
|
3250
3334
|
params: { receive_id_type: "chat_id" },
|
|
3251
3335
|
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)
|