openclaw-lark-multi-agent 1.2.8 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -12
- package/dist/cli.js +67 -10
- package/dist/feishu-bot.js +57 -3
- package/dist/live-status.d.ts +1 -1
- package/dist/live-status.js +10 -0
- package/dist/openclaw-client.d.ts +44 -0
- package/dist/openclaw-client.js +174 -0
- package/package.json +9 -2
- package/plugins/lma-steer/README.md +45 -0
- package/plugins/lma-steer/dist/index.js +100 -0
- package/plugins/lma-steer/openclaw.plugin.json +14 -0
- package/plugins/lma-steer/package.json +18 -0
package/README.md
CHANGED
|
@@ -70,9 +70,12 @@ After the package is published to npm:
|
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
npm install -g openclaw-lark-multi-agent
|
|
73
|
-
|
|
73
|
+
lma init
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
> The command is `lma` (short). It is also available under its full name
|
|
77
|
+
> `openclaw-lark-multi-agent` for back-compat.
|
|
78
|
+
|
|
76
79
|
This creates:
|
|
77
80
|
|
|
78
81
|
- config: `~/.openclaw/openclaw-lark-multi-agent/config.json`
|
|
@@ -81,29 +84,47 @@ This creates:
|
|
|
81
84
|
Edit the generated config and fill in your OpenClaw Gateway token and Lark app credentials. Then run:
|
|
82
85
|
|
|
83
86
|
```bash
|
|
84
|
-
|
|
87
|
+
lma start
|
|
85
88
|
```
|
|
86
89
|
|
|
87
90
|
Install as a systemd user service:
|
|
88
91
|
|
|
89
92
|
```bash
|
|
90
|
-
|
|
93
|
+
lma install-systemd --user
|
|
91
94
|
```
|
|
92
95
|
|
|
93
96
|
On Windows, install [NSSM](https://nssm.cc/download), make sure `nssm.exe` is in `PATH`, then run PowerShell or Command Prompt as Administrator:
|
|
94
97
|
|
|
95
98
|
```powershell
|
|
96
|
-
|
|
99
|
+
lma install-windows-service
|
|
97
100
|
```
|
|
98
101
|
|
|
102
|
+
### Optional: real-time steering plugin
|
|
103
|
+
|
|
104
|
+
To let users nudge/correct a long-running agent turn in real time (instead of
|
|
105
|
+
waiting for it to finish), install the bundled `lma-steer` OpenClaw plugin:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
lma install-steer-plugin
|
|
109
|
+
# then restart the OpenClaw gateway so it loads the plugin:
|
|
110
|
+
systemctl --user restart openclaw-gateway.service
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When the agent is mid-run and a new message arrives, the bridge injects it into
|
|
114
|
+
the active run at the next tool-call boundary (Feishu shows the "Get" reaction).
|
|
115
|
+
Without the plugin, the bridge falls back to queuing the message until the run
|
|
116
|
+
finishes — everything still works, just without real-time steering. See
|
|
117
|
+
`plugins/lma-steer/README.md` for details.
|
|
118
|
+
|
|
99
119
|
Useful CLI commands:
|
|
100
120
|
|
|
101
121
|
```bash
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
lma --help
|
|
123
|
+
lma doctor
|
|
124
|
+
lma start [config]
|
|
125
|
+
lma init [--state-dir DIR] [--force]
|
|
126
|
+
lma install-systemd [--user|--system] [--state-dir DIR]
|
|
127
|
+
lma install-steer-plugin [--no-force]
|
|
107
128
|
```
|
|
108
129
|
|
|
109
130
|
## Quick start from source
|
|
@@ -194,15 +215,15 @@ The npm CLI works on Windows as well. It uses `%USERPROFILE%\.openclaw\openclaw-
|
|
|
194
215
|
|
|
195
216
|
```powershell
|
|
196
217
|
npm install -g openclaw-lark-multi-agent
|
|
197
|
-
|
|
218
|
+
lma init
|
|
198
219
|
notepad $env:USERPROFILE\.openclaw\openclaw-lark-multi-agent\config.json
|
|
199
|
-
|
|
220
|
+
lma start
|
|
200
221
|
```
|
|
201
222
|
|
|
202
223
|
To run as a Windows service, install [NSSM](https://nssm.cc/download), put `nssm.exe` in `PATH`, open an elevated terminal, then run:
|
|
203
224
|
|
|
204
225
|
```powershell
|
|
205
|
-
|
|
226
|
+
lma install-windows-service
|
|
206
227
|
```
|
|
207
228
|
|
|
208
229
|
A legacy helper script is also available at `scripts/install-windows-service.bat`.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,11 @@ import { execFileSync } from "node:child_process";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { startApp } from "./index.js";
|
|
8
8
|
const APP_NAME = "openclaw-lark-multi-agent";
|
|
9
|
+
// Short command name shown in help/usage. The published binary is available as
|
|
10
|
+
// both `lma` (preferred) and `openclaw-lark-multi-agent` (back-compat). APP_NAME
|
|
11
|
+
// stays as the long identifier for the state dir and systemd unit so existing
|
|
12
|
+
// installs keep working; only the user-facing command label is shortened.
|
|
13
|
+
const CLI_NAME = "lma";
|
|
9
14
|
const HOME_DIR = homedir() || process.env.HOME || process.cwd();
|
|
10
15
|
const DEFAULT_STATE_DIR = resolve(HOME_DIR, ".openclaw", APP_NAME);
|
|
11
16
|
const DEFAULT_CONFIG_PATH = resolve(DEFAULT_STATE_DIR, "config.json");
|
|
@@ -13,18 +18,22 @@ function usage(exitCode = 0) {
|
|
|
13
18
|
console.log(`OpenClaw Lark Multi-Agent
|
|
14
19
|
|
|
15
20
|
Usage:
|
|
16
|
-
${
|
|
17
|
-
${
|
|
18
|
-
${
|
|
19
|
-
${
|
|
20
|
-
${
|
|
21
|
-
${
|
|
21
|
+
${CLI_NAME} start [config]
|
|
22
|
+
${CLI_NAME} init [--state-dir DIR] [--force]
|
|
23
|
+
${CLI_NAME} install-systemd [--user|--system] [--state-dir DIR] [--no-restart]
|
|
24
|
+
${CLI_NAME} install-windows-service [--state-dir DIR] [--no-start]
|
|
25
|
+
${CLI_NAME} install-steer-plugin [--no-force]
|
|
26
|
+
${CLI_NAME} doctor [--state-dir DIR]
|
|
27
|
+
${CLI_NAME} --help
|
|
22
28
|
|
|
23
29
|
Examples:
|
|
24
|
-
${
|
|
25
|
-
${
|
|
26
|
-
${
|
|
27
|
-
${
|
|
30
|
+
${CLI_NAME} init
|
|
31
|
+
${CLI_NAME} start ~/.openclaw/${APP_NAME}/config.json
|
|
32
|
+
${CLI_NAME} install-systemd --user
|
|
33
|
+
${CLI_NAME} install-windows-service
|
|
34
|
+
${CLI_NAME} install-steer-plugin
|
|
35
|
+
|
|
36
|
+
(The command is also available as \`${APP_NAME}\` for back-compat.)
|
|
28
37
|
`);
|
|
29
38
|
process.exit(exitCode);
|
|
30
39
|
}
|
|
@@ -172,6 +181,52 @@ function cmdInstallWindowsService(args) {
|
|
|
172
181
|
run("nssm", ["start", APP_NAME]);
|
|
173
182
|
console.log(`Installed Windows service: ${APP_NAME}`);
|
|
174
183
|
}
|
|
184
|
+
function runCapture(cmd, args) {
|
|
185
|
+
try {
|
|
186
|
+
execFileSync(cmd, args, { stdio: "inherit" });
|
|
187
|
+
return { code: 0 };
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return { code: typeof err?.status === "number" ? err.status : 1 };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Install the bundled lma-steer OpenClaw plugin into the local OpenClaw gateway.
|
|
195
|
+
* The plugin registers the `lma.steer` gateway method that the bridge uses to
|
|
196
|
+
* inject a message into an active run at the next tool-call boundary. The plugin
|
|
197
|
+
* ships inside this package under plugins/lma-steer (built to dist).
|
|
198
|
+
*/
|
|
199
|
+
function cmdInstallSteerPlugin(args) {
|
|
200
|
+
// Default to --force so reinstalling an updated bundled plugin overwrites the
|
|
201
|
+
// previously installed copy. Pass --no-force to keep an existing install.
|
|
202
|
+
const noForce = hasFlag(args, "--no-force");
|
|
203
|
+
hasFlag(args, "--force");
|
|
204
|
+
if (args.length > 0)
|
|
205
|
+
throw new Error(`Unknown install-steer-plugin arguments: ${args.join(" ")}`);
|
|
206
|
+
// Resolve the bundled plugin dir relative to this CLI file: dist/cli.js -> ../plugins/lma-steer.
|
|
207
|
+
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
208
|
+
const pluginDir = resolve(cliDir, "..", "plugins", "lma-steer");
|
|
209
|
+
if (!existsSync(resolve(pluginDir, "openclaw.plugin.json")) || !existsSync(resolve(pluginDir, "dist", "index.js"))) {
|
|
210
|
+
throw new Error(`bundled lma-steer plugin not found or not built at: ${pluginDir}`);
|
|
211
|
+
}
|
|
212
|
+
const openclawBin = process.env.OPENCLAW_BIN || "openclaw";
|
|
213
|
+
const installArgs = ["plugins", "install", pluginDir];
|
|
214
|
+
if (!noForce)
|
|
215
|
+
installArgs.push("--force");
|
|
216
|
+
console.log(`Installing lma-steer plugin from: ${pluginDir}`);
|
|
217
|
+
const res = runCapture(openclawBin, installArgs);
|
|
218
|
+
if (res.code !== 0) {
|
|
219
|
+
console.error(`\n"${openclawBin} plugins install" failed (exit ${res.code}).`);
|
|
220
|
+
console.error(`Make sure the OpenClaw CLI is installed and on PATH (or set OPENCLAW_BIN),`);
|
|
221
|
+
console.error(`then rerun: ${CLI_NAME} install-steer-plugin`);
|
|
222
|
+
process.exit(res.code);
|
|
223
|
+
}
|
|
224
|
+
console.log(`\nlma-steer plugin installed. Restart the OpenClaw gateway to load it, e.g.:`);
|
|
225
|
+
console.log(` systemctl --user restart openclaw-gateway.service`);
|
|
226
|
+
console.log(`Then verify:`);
|
|
227
|
+
console.log(` openclaw gateway call lma.steer --params '{"sessionKey":"test","text":"ping"}'`);
|
|
228
|
+
console.log(` (expect {"status":"no_active_run"} for an idle session)`);
|
|
229
|
+
}
|
|
175
230
|
function cmdDoctor(args) {
|
|
176
231
|
const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
|
|
177
232
|
if (args.length > 0)
|
|
@@ -194,6 +249,8 @@ async function main() {
|
|
|
194
249
|
return cmdInstallSystemd(args);
|
|
195
250
|
if (cmd === "install-windows-service")
|
|
196
251
|
return cmdInstallWindowsService(args);
|
|
252
|
+
if (cmd === "install-steer-plugin")
|
|
253
|
+
return cmdInstallSteerPlugin(args);
|
|
197
254
|
if (cmd === "doctor")
|
|
198
255
|
return cmdDoctor(args);
|
|
199
256
|
if (cmd === "start") {
|
package/dist/feishu-bot.js
CHANGED
|
@@ -29,6 +29,17 @@ function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AU
|
|
|
29
29
|
// for the locale-appropriate phrase; detection accepts BOTH so either works.
|
|
30
30
|
const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
|
|
31
31
|
const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
|
|
32
|
+
// Prefix wrapped around a steered (mid-run inserted) message so the agent knows
|
|
33
|
+
// it is a NEW user message that arrived while it was still working, and should
|
|
34
|
+
// be handled together with the current task. The live-status card and reactions
|
|
35
|
+
// still key off the ORIGINAL text; only what OpenClaw receives is wrapped.
|
|
36
|
+
function steerInjectionPrefix(en) {
|
|
37
|
+
const zh = process.env.OPENCLAW_LARK_MULTI_AGENT_STEER_PREFIX
|
|
38
|
+
|| "【用户中途插入的新消息,请与当前任务一起处理】";
|
|
39
|
+
const enText = process.env.OPENCLAW_LARK_MULTI_AGENT_STEER_PREFIX_EN
|
|
40
|
+
|| "[New message inserted by the user mid-task; please handle it together with the current task]";
|
|
41
|
+
return en ? enText : zh;
|
|
42
|
+
}
|
|
32
43
|
// How many of the most-recent tool calls (and their results) tool-trim keeps
|
|
33
44
|
// intact — recent tool calls may still be relevant to the agent's next step.
|
|
34
45
|
function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
|
|
@@ -895,11 +906,53 @@ export class FeishuBot {
|
|
|
895
906
|
const BUSY_TIMEOUT_MS = 1_800_000; // 30 minutes, matches collectReply safety timeout
|
|
896
907
|
const isBusy = busySince > 0 && (Date.now() - busySince) < BUSY_TIMEOUT_MS;
|
|
897
908
|
if (isBusy) {
|
|
898
|
-
//
|
|
909
|
+
// The agent is mid-run. Instead of only queuing to wait for the current
|
|
910
|
+
// run to finish, try to STEER this message into the active run so it is
|
|
911
|
+
// seen at the next tool-call boundary (lets the user nudge/correct a long
|
|
912
|
+
// run in real time). The lma-steer plugin reports whether it landed.
|
|
913
|
+
const sessionKeyForSteer = this.getSessionKey(chatId);
|
|
914
|
+
// Wrap the message so the agent knows it is a NEW user message inserted
|
|
915
|
+
// mid-task. The live-status line and reactions still key off the original.
|
|
916
|
+
const steerWrapped = `${steerInjectionPrefix(this.isEn(chatId))}\n${cleanText}`;
|
|
917
|
+
const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
|
|
918
|
+
if (steer.status === "steered") {
|
|
919
|
+
// Accepted into the active run's steer queue, but NOT yet consumed by
|
|
920
|
+
// the model. Show "Typing" (awaiting insertion) now; flip to "Get" only
|
|
921
|
+
// when the model actually consumes it at a tool-call boundary (a
|
|
922
|
+
// matching sessionUser event), which also renders it on the live-status
|
|
923
|
+
// card in correct time order.
|
|
924
|
+
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
925
|
+
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
926
|
+
this.pendingAckMessages.set(chatId, pending);
|
|
927
|
+
const norm = cleanText.replace(/\s+/g, " ").trim();
|
|
928
|
+
this.openclawClient.onSteerConsumed(sessionKeyForSteer, (consumedText) => {
|
|
929
|
+
const cn = (consumedText || "").replace(/\s+/g, " ").trim();
|
|
930
|
+
if (cn !== norm && !cn.includes(norm))
|
|
931
|
+
return false; // not ours; keep waiting
|
|
932
|
+
// Flip Typing -> Get now that the model has actually read it.
|
|
933
|
+
void this.removeReaction(messageId, "Typing").catch(() => { });
|
|
934
|
+
void this.addReaction(messageId, "Get").catch(() => { });
|
|
935
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
936
|
+
for (const a of acks)
|
|
937
|
+
if (a.messageId === messageId)
|
|
938
|
+
a.emoji = "Get";
|
|
939
|
+
this.pendingAckMessages.set(chatId, acks);
|
|
940
|
+
return true; // one-shot: matched, remove this callback
|
|
941
|
+
});
|
|
942
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, STEERED (awaiting consumption): "${cleanText.substring(0, 50)}..."`);
|
|
943
|
+
// The message is already in DB with a pending trigger. Clear that
|
|
944
|
+
// trigger so it is NOT re-run as a separate follow-up batch after the
|
|
945
|
+
// current run ends — it has already been injected into this run.
|
|
946
|
+
if (insertedId > 0)
|
|
947
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
// Could not steer (no active run raced to idle, rejected, or plugin
|
|
951
|
+
// unavailable): fall back to the original behavior — queue and wait.
|
|
899
952
|
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
900
953
|
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
901
954
|
this.pendingAckMessages.set(chatId, pending);
|
|
902
|
-
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
|
|
955
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
903
956
|
return; // Message is in DB, will be picked up when agent finishes
|
|
904
957
|
}
|
|
905
958
|
if (busySince > 0) {
|
|
@@ -2225,7 +2278,8 @@ export class FeishuBot {
|
|
|
2225
2278
|
: kind === "tool_end" ? "✓"
|
|
2226
2279
|
: kind === "lifecycle" ? "⋯"
|
|
2227
2280
|
: kind === "summary" ? "📊"
|
|
2228
|
-
: "
|
|
2281
|
+
: kind === "steer" ? "💬"
|
|
2282
|
+
: "•";
|
|
2229
2283
|
const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
|
2230
2284
|
const content = view.lines
|
|
2231
2285
|
.map((l) => {
|
package/dist/live-status.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ProgressEvent } from "./openclaw-client";
|
|
2
2
|
/** One activity line shown in the card content area. */
|
|
3
3
|
export type LiveStatusLine = {
|
|
4
|
-
kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary";
|
|
4
|
+
kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary" | "steer";
|
|
5
5
|
text: string;
|
|
6
6
|
/** Relative time (seconds since run start) when this line was produced. */
|
|
7
7
|
at: number;
|
package/dist/live-status.js
CHANGED
|
@@ -83,6 +83,16 @@ export class LiveStatusController {
|
|
|
83
83
|
return;
|
|
84
84
|
this.appendText(t);
|
|
85
85
|
}
|
|
86
|
+
else if (event.kind === "steer") {
|
|
87
|
+
// A user message that was steered into THIS run and has now actually been
|
|
88
|
+
// consumed by the model at a tool-call boundary. It is appended as its own
|
|
89
|
+
// line so it lands in the correct time-order position (right after the
|
|
90
|
+
// tool that just finished), not merged with assistant text.
|
|
91
|
+
const t = (event.text || "").trim();
|
|
92
|
+
if (!t)
|
|
93
|
+
return;
|
|
94
|
+
this.pushLine("steer", t);
|
|
95
|
+
}
|
|
86
96
|
else {
|
|
87
97
|
return; // lifecycle ticks: ignore (footer timer already advances)
|
|
88
98
|
}
|
|
@@ -8,6 +8,9 @@ export type ProgressEvent = {
|
|
|
8
8
|
} | {
|
|
9
9
|
kind: "assistant_note";
|
|
10
10
|
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "steer";
|
|
13
|
+
text: string;
|
|
11
14
|
} | {
|
|
12
15
|
kind: "lifecycle";
|
|
13
16
|
text: string;
|
|
@@ -39,6 +42,10 @@ export declare class OpenClawClient {
|
|
|
39
42
|
private lastToolNamesByItemId;
|
|
40
43
|
private sessionMessageCallbacks;
|
|
41
44
|
private progressCallbacks;
|
|
45
|
+
/** Per-session steered messages awaiting consumption confirmation. Maps the
|
|
46
|
+
* normalized text actually sent (possibly wrapped with an insertion prefix)
|
|
47
|
+
* to the ORIGINAL display text shown on the live-status card. */
|
|
48
|
+
private pendingSteerTexts;
|
|
42
49
|
/** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
|
|
43
50
|
private verboseTranscriptSessions;
|
|
44
51
|
private verboseAssistantTimers;
|
|
@@ -116,6 +123,43 @@ export declare class OpenClawClient {
|
|
|
116
123
|
message: string;
|
|
117
124
|
label?: string;
|
|
118
125
|
}): Promise<any>;
|
|
126
|
+
/**
|
|
127
|
+
* Steer a message into the ACTIVE run for a session via the lma-steer plugin's
|
|
128
|
+
* `lma.steer` gateway method. Unlike a plain new message, this injects the text
|
|
129
|
+
* at the next tool-call boundary of the run that is currently executing, so the
|
|
130
|
+
* user can nudge/correct a long run instead of waiting for it to finish.
|
|
131
|
+
*
|
|
132
|
+
* Returns a structured status so the bridge can render the right reaction:
|
|
133
|
+
* - "steered" -> queued into the active run (will be seen next boundary)
|
|
134
|
+
* - "no_active_run" -> no active run; caller should fall back to normal queueing
|
|
135
|
+
* - "rejected" -> active run refused the injection (compacting/not streaming)
|
|
136
|
+
* - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
|
|
137
|
+
* installed or gateway not restarted); caller falls back.
|
|
138
|
+
*/
|
|
139
|
+
steer(sessionKey: string, text: string, displayText?: string): Promise<{
|
|
140
|
+
status: "steered" | "no_active_run" | "rejected" | "unavailable";
|
|
141
|
+
sessionId?: string;
|
|
142
|
+
}>;
|
|
143
|
+
/** Callbacks fired when a steered message is confirmed consumed by the model
|
|
144
|
+
* (matching sessionUser event). Keyed by bare/short session key. Multiple may
|
|
145
|
+
* be registered when several messages are steered during one run; each is
|
|
146
|
+
* invoked with the consumed text and decides whether it is the one it wants.
|
|
147
|
+
* Callbacks are one-shot: returning true removes them. */
|
|
148
|
+
private steerConsumedCallbacks;
|
|
149
|
+
/** Register a callback (per bare session key) invoked when a steered message
|
|
150
|
+
* is actually consumed by the active run. The bridge uses this to flip the
|
|
151
|
+
* Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
|
|
152
|
+
* Return true from the callback to consume/remove it (one-shot). */
|
|
153
|
+
onSteerConsumed(sessionKey: string, cb: (text: string) => boolean | void): void;
|
|
154
|
+
private shortKey;
|
|
155
|
+
private registerPendingSteer;
|
|
156
|
+
/**
|
|
157
|
+
* Called when a sessionUser event is seen for a session. If its text matches a
|
|
158
|
+
* pending steered message, fire the live-status `steer` line (correct time
|
|
159
|
+
* order, showing the ORIGINAL text) and the steer-consumed callback, then
|
|
160
|
+
* clear that pending entry. Returns true if it matched a steered message.
|
|
161
|
+
*/
|
|
162
|
+
private handleSteerConsumption;
|
|
119
163
|
/**
|
|
120
164
|
* Get session info (model, tokens, etc.) for status display.
|
|
121
165
|
*/
|
package/dist/openclaw-client.js
CHANGED
|
@@ -30,6 +30,10 @@ export class OpenClawClient {
|
|
|
30
30
|
lastToolNamesByItemId = new Map();
|
|
31
31
|
sessionMessageCallbacks = new Map();
|
|
32
32
|
progressCallbacks = new Map();
|
|
33
|
+
/** Per-session steered messages awaiting consumption confirmation. Maps the
|
|
34
|
+
* normalized text actually sent (possibly wrapped with an insertion prefix)
|
|
35
|
+
* to the ORIGINAL display text shown on the live-status card. */
|
|
36
|
+
pendingSteerTexts = new Map();
|
|
33
37
|
/** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
|
|
34
38
|
verboseTranscriptSessions = new Set();
|
|
35
39
|
verboseAssistantTimers = new Map();
|
|
@@ -211,6 +215,11 @@ export class OpenClawClient {
|
|
|
211
215
|
stream: "sessionUser",
|
|
212
216
|
data: { text: visibleUserText },
|
|
213
217
|
});
|
|
218
|
+
// If this user message is one we steered into the run, the model has
|
|
219
|
+
// now actually consumed it (session.message is emitted as the message
|
|
220
|
+
// is committed to the transcript). Render it on the live-status card
|
|
221
|
+
// in correct time order and flip the reaction to Get.
|
|
222
|
+
this.handleSteerConsumption(rawKey, visibleUserText);
|
|
214
223
|
}
|
|
215
224
|
const visibleAssistantText = this.extractVisibleAssistantText(msg);
|
|
216
225
|
if (visibleAssistantText) {
|
|
@@ -287,6 +296,21 @@ export class OpenClawClient {
|
|
|
287
296
|
this.connected = false;
|
|
288
297
|
this.connectPromise = null;
|
|
289
298
|
console.log("[OpenClaw] WS disconnected");
|
|
299
|
+
// Reject all in-flight RPCs so callers fail fast instead of hanging until
|
|
300
|
+
// their timeout. Combined with the collectReply disconnect check, this
|
|
301
|
+
// lets the bridge release busy locks promptly on a gateway restart.
|
|
302
|
+
if (this.pending.size > 0) {
|
|
303
|
+
const err = new Error("Gateway disconnected");
|
|
304
|
+
for (const [id, entry] of this.pending) {
|
|
305
|
+
if (entry.timer)
|
|
306
|
+
clearTimeout(entry.timer);
|
|
307
|
+
try {
|
|
308
|
+
entry.reject(err);
|
|
309
|
+
}
|
|
310
|
+
catch { /* ignore */ }
|
|
311
|
+
this.pending.delete(id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
290
314
|
if (this.shouldReconnect) {
|
|
291
315
|
this.scheduleReconnect();
|
|
292
316
|
}
|
|
@@ -489,6 +513,11 @@ export class OpenClawClient {
|
|
|
489
513
|
*/
|
|
490
514
|
collectReply(runId, timeoutMs = 1800000, targetSessionKey, options) {
|
|
491
515
|
return new Promise((resolve, reject) => {
|
|
516
|
+
// Whether we were connected when the run started. We only treat a later
|
|
517
|
+
// disconnect as a mid-run gateway drop (fail fast) if we began connected;
|
|
518
|
+
// this keeps unit tests that drive collectReply without a live socket from
|
|
519
|
+
// tripping the disconnect path.
|
|
520
|
+
const startedConnected = this.connected;
|
|
492
521
|
let text = "";
|
|
493
522
|
let chatDeltaText = "";
|
|
494
523
|
let chatFinalText = "";
|
|
@@ -607,6 +636,28 @@ export class OpenClawClient {
|
|
|
607
636
|
resolve(finalText);
|
|
608
637
|
};
|
|
609
638
|
const poller = setInterval(() => {
|
|
639
|
+
// Gateway connection dropped mid-run (e.g. gateway restart): the run will
|
|
640
|
+
// never deliver a reply on this connection, so stop waiting instead of
|
|
641
|
+
// hanging until the 30-min idle timeout. Failing fast lets the bridge
|
|
642
|
+
// release its busy lock so new messages are not stuck "waiting".
|
|
643
|
+
if (startedConnected && !this.connected) {
|
|
644
|
+
clearInterval(poller);
|
|
645
|
+
if (chatFinalTimer)
|
|
646
|
+
clearTimeout(chatFinalTimer);
|
|
647
|
+
if (lifecycleEndTimer)
|
|
648
|
+
clearTimeout(lifecycleEndTimer);
|
|
649
|
+
if (replayInvalidTimer)
|
|
650
|
+
clearTimeout(replayInvalidTimer);
|
|
651
|
+
if (idleTimer)
|
|
652
|
+
clearTimeout(idleTimer);
|
|
653
|
+
const salvaged = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
654
|
+
if (salvaged) {
|
|
655
|
+
resolve(salvaged);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
reject(new Error("Gateway disconnected during run"));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
610
661
|
// /stop force-abort: finish immediately instead of waiting for the
|
|
611
662
|
// cancelled lifecycle to be treated as a transient state (idle timeout).
|
|
612
663
|
if (sessionKey && (this.forceAbortedSessions.has(sessionKey) || this.forceAbortedSessions.has(shortSessionKey))) {
|
|
@@ -920,6 +971,129 @@ export class OpenClawClient {
|
|
|
920
971
|
async injectAssistantMessage(params) {
|
|
921
972
|
return this.rpc("chat.inject", params, 10000);
|
|
922
973
|
}
|
|
974
|
+
/**
|
|
975
|
+
* Steer a message into the ACTIVE run for a session via the lma-steer plugin's
|
|
976
|
+
* `lma.steer` gateway method. Unlike a plain new message, this injects the text
|
|
977
|
+
* at the next tool-call boundary of the run that is currently executing, so the
|
|
978
|
+
* user can nudge/correct a long run instead of waiting for it to finish.
|
|
979
|
+
*
|
|
980
|
+
* Returns a structured status so the bridge can render the right reaction:
|
|
981
|
+
* - "steered" -> queued into the active run (will be seen next boundary)
|
|
982
|
+
* - "no_active_run" -> no active run; caller should fall back to normal queueing
|
|
983
|
+
* - "rejected" -> active run refused the injection (compacting/not streaming)
|
|
984
|
+
* - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
|
|
985
|
+
* installed or gateway not restarted); caller falls back.
|
|
986
|
+
*/
|
|
987
|
+
async steer(sessionKey, text, displayText) {
|
|
988
|
+
try {
|
|
989
|
+
const res = await this.rpc("lma.steer", { sessionKey, text }, 10000);
|
|
990
|
+
const status = res?.status;
|
|
991
|
+
if (status === "steered" || status === "no_active_run" || status === "rejected") {
|
|
992
|
+
// On a successful steer, remember the text so that when the model
|
|
993
|
+
// actually consumes it (a matching sessionUser event on THIS run) we can
|
|
994
|
+
// render it on the live-status card in correct time order and let the
|
|
995
|
+
// bridge flip the reaction to Get only after real consumption. Match on
|
|
996
|
+
// the (possibly wrapped) text actually sent; display the original.
|
|
997
|
+
if (status === "steered")
|
|
998
|
+
this.registerPendingSteer(sessionKey, text, displayText);
|
|
999
|
+
return { status, sessionId: typeof res?.sessionId === "string" ? res.sessionId : undefined };
|
|
1000
|
+
}
|
|
1001
|
+
return { status: "unavailable" };
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
// Unknown method / plugin missing / transient RPC error -> caller falls back
|
|
1005
|
+
// to normal queueing. Never throw into the message path.
|
|
1006
|
+
const msg = err?.message || String(err);
|
|
1007
|
+
if (!/unknown method/i.test(msg)) {
|
|
1008
|
+
console.warn(`[OpenClaw] lma.steer failed for ${sessionKey.slice(-8)}: ${msg}`);
|
|
1009
|
+
}
|
|
1010
|
+
return { status: "unavailable" };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/** Callbacks fired when a steered message is confirmed consumed by the model
|
|
1014
|
+
* (matching sessionUser event). Keyed by bare/short session key. Multiple may
|
|
1015
|
+
* be registered when several messages are steered during one run; each is
|
|
1016
|
+
* invoked with the consumed text and decides whether it is the one it wants.
|
|
1017
|
+
* Callbacks are one-shot: returning true removes them. */
|
|
1018
|
+
steerConsumedCallbacks = new Map();
|
|
1019
|
+
/** Register a callback (per bare session key) invoked when a steered message
|
|
1020
|
+
* is actually consumed by the active run. The bridge uses this to flip the
|
|
1021
|
+
* Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
|
|
1022
|
+
* Return true from the callback to consume/remove it (one-shot). */
|
|
1023
|
+
onSteerConsumed(sessionKey, cb) {
|
|
1024
|
+
const key = this.shortKey(sessionKey);
|
|
1025
|
+
let list = this.steerConsumedCallbacks.get(key);
|
|
1026
|
+
if (!list) {
|
|
1027
|
+
list = [];
|
|
1028
|
+
this.steerConsumedCallbacks.set(key, list);
|
|
1029
|
+
}
|
|
1030
|
+
list.push(cb);
|
|
1031
|
+
}
|
|
1032
|
+
shortKey(sessionKey) {
|
|
1033
|
+
return sessionKey.replace(/^agent:[^:]+:/, "");
|
|
1034
|
+
}
|
|
1035
|
+
registerPendingSteer(sessionKey, text, displayText) {
|
|
1036
|
+
const key = this.shortKey(sessionKey);
|
|
1037
|
+
const norm = text.replace(/\s+/g, " ").trim();
|
|
1038
|
+
if (!norm)
|
|
1039
|
+
return;
|
|
1040
|
+
let map = this.pendingSteerTexts.get(key);
|
|
1041
|
+
if (!map) {
|
|
1042
|
+
map = new Map();
|
|
1043
|
+
this.pendingSteerTexts.set(key, map);
|
|
1044
|
+
}
|
|
1045
|
+
map.set(norm, (displayText || text).trim());
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Called when a sessionUser event is seen for a session. If its text matches a
|
|
1049
|
+
* pending steered message, fire the live-status `steer` line (correct time
|
|
1050
|
+
* order, showing the ORIGINAL text) and the steer-consumed callback, then
|
|
1051
|
+
* clear that pending entry. Returns true if it matched a steered message.
|
|
1052
|
+
*/
|
|
1053
|
+
handleSteerConsumption(sessionKey, text) {
|
|
1054
|
+
const key = this.shortKey(sessionKey);
|
|
1055
|
+
const map = this.pendingSteerTexts.get(key);
|
|
1056
|
+
if (!map || map.size === 0)
|
|
1057
|
+
return false;
|
|
1058
|
+
const norm = (text || "").replace(/\s+/g, " ").trim();
|
|
1059
|
+
if (!norm)
|
|
1060
|
+
return false;
|
|
1061
|
+
// Match exact, or the sessionUser text containing the steered text (the run
|
|
1062
|
+
// may wrap it). Prefer the longest matching pending entry.
|
|
1063
|
+
let matched;
|
|
1064
|
+
for (const pending of map.keys()) {
|
|
1065
|
+
if (norm === pending || norm.includes(pending)) {
|
|
1066
|
+
if (!matched || pending.length > matched.length)
|
|
1067
|
+
matched = pending;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (!matched)
|
|
1071
|
+
return false;
|
|
1072
|
+
const display = map.get(matched) || matched;
|
|
1073
|
+
map.delete(matched);
|
|
1074
|
+
if (map.size === 0)
|
|
1075
|
+
this.pendingSteerTexts.delete(key);
|
|
1076
|
+
const progressCb = this.progressCallbacks.get(key) || this.progressCallbacks.get(`agent:main:${key}`);
|
|
1077
|
+
if (progressCb)
|
|
1078
|
+
void progressCb({ kind: "steer", text: display });
|
|
1079
|
+
const list = this.steerConsumedCallbacks.get(key);
|
|
1080
|
+
if (list && list.length > 0) {
|
|
1081
|
+
// Invoke callbacks; a callback returning true is one-shot and removed.
|
|
1082
|
+
const remaining = list.filter((cb) => {
|
|
1083
|
+
try {
|
|
1084
|
+
return cb(matched) !== true;
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
if (remaining.length > 0)
|
|
1091
|
+
this.steerConsumedCallbacks.set(key, remaining);
|
|
1092
|
+
else
|
|
1093
|
+
this.steerConsumedCallbacks.delete(key);
|
|
1094
|
+
}
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
923
1097
|
/**
|
|
924
1098
|
* Get session info (model, tokens, etc.) for status display.
|
|
925
1099
|
*/
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-lark-multi-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "tsc",
|
|
7
|
+
"build": "tsc && npm run build:steer-plugin",
|
|
8
|
+
"build:steer-plugin": "tsc -p plugins/lma-steer/tsconfig.json",
|
|
8
9
|
"start": "node dist/index.js",
|
|
9
10
|
"dev": "tsx src/index.ts",
|
|
10
11
|
"test": "vitest run",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"@types/better-sqlite3": "^7.6.13",
|
|
23
24
|
"@types/node": "^22.0.0",
|
|
24
25
|
"@vitest/coverage-v8": "^4.1.5",
|
|
26
|
+
"openclaw": "^2026.6.0",
|
|
25
27
|
"tsx": "^4.19.0",
|
|
26
28
|
"typescript": "^5.7.0",
|
|
27
29
|
"vitest": "^4.1.5"
|
|
@@ -45,11 +47,16 @@
|
|
|
45
47
|
"gateway"
|
|
46
48
|
],
|
|
47
49
|
"bin": {
|
|
50
|
+
"lma": "dist/cli.js",
|
|
48
51
|
"openclaw-lark-multi-agent": "dist/cli.js"
|
|
49
52
|
},
|
|
50
53
|
"files": [
|
|
51
54
|
"dist",
|
|
52
55
|
"scripts",
|
|
56
|
+
"plugins/lma-steer/dist",
|
|
57
|
+
"plugins/lma-steer/openclaw.plugin.json",
|
|
58
|
+
"plugins/lma-steer/package.json",
|
|
59
|
+
"plugins/lma-steer/README.md",
|
|
53
60
|
"config.example.json",
|
|
54
61
|
"README.md",
|
|
55
62
|
"README.zh-CN.md",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# lma-steer
|
|
2
|
+
|
|
3
|
+
An OpenClaw plugin bundled with **openclaw-lark-multi-agent**. It registers a
|
|
4
|
+
Gateway method `lma.steer` that the bridge uses to inject a message into an
|
|
5
|
+
**active** OpenClaw run at the next tool-call boundary — so a user can nudge or
|
|
6
|
+
correct a long-running task in real time instead of waiting for it to finish.
|
|
7
|
+
|
|
8
|
+
## Why a plugin
|
|
9
|
+
|
|
10
|
+
Native `chat.send` with `queue.mode=steer` only acks `{status:"started"}` — it
|
|
11
|
+
cannot tell the bridge whether the message actually steered into the active run,
|
|
12
|
+
was queued as a followup, or was rejected. This plugin uses the runtime
|
|
13
|
+
primitives `resolveActiveEmbeddedRunSessionId` + `queueAgentHarnessMessage`,
|
|
14
|
+
which expose that distinction, so the bridge can render an accurate Feishu
|
|
15
|
+
reaction.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
From an installed `openclaw-lark-multi-agent`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
openclaw-lark-multi-agent install-steer-plugin
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then restart the OpenClaw gateway so it loads the plugin:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
systemctl --user restart openclaw-gateway.service
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Verify (an idle session returns `no_active_run`):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
openclaw gateway call lma.steer --params '{"sessionKey":"test","text":"ping"}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Gateway method
|
|
38
|
+
|
|
39
|
+
`lma.steer`
|
|
40
|
+
|
|
41
|
+
- params: `{ sessionKey: string, text: string }`
|
|
42
|
+
- result: `{ status: "steered" | "no_active_run" | "rejected", sessionId?: string }`
|
|
43
|
+
- `steered` — queued into the active run; seen at the next tool-call boundary
|
|
44
|
+
- `no_active_run` — no active run; caller should send as a normal new message
|
|
45
|
+
- `rejected` — active run refused the injection (e.g. compacting / not streaming)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { resolveActiveEmbeddedRunSessionId, queueAgentHarnessMessage, } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
3
|
+
const ERR_INVALID = "INVALID_REQUEST";
|
|
4
|
+
// A run can be accepted but not yet resolvable/streaming (cold-start window).
|
|
5
|
+
// Wait up to STEER_WAIT_MS (polling every STEER_POLL_MS) for it to become
|
|
6
|
+
// steerable before falling back to no_active_run. Env-tunable.
|
|
7
|
+
const STEER_WAIT_MS = Number(process.env.LMA_STEER_WAIT_MS || 8000);
|
|
8
|
+
const STEER_POLL_MS = Number(process.env.LMA_STEER_POLL_MS || 300);
|
|
9
|
+
function sleep(ms) {
|
|
10
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
}
|
|
12
|
+
function readString(value) {
|
|
13
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Candidate session keys to try, in order. OpenClaw registers active runs under
|
|
17
|
+
* the fully-qualified key `agent:<agentId>:<key>` (e.g. agent:main:lma-claude-
|
|
18
|
+
* oc_xxx), but callers such as the LMA bridge pass the bare key (lma-claude-
|
|
19
|
+
* oc_xxx). Try the key as given first, then the agent:main:-qualified form, so
|
|
20
|
+
* both conventions resolve to the same active run.
|
|
21
|
+
*/
|
|
22
|
+
function sessionKeyCandidates(sessionKey) {
|
|
23
|
+
const out = [sessionKey];
|
|
24
|
+
if (!/^agent:/.test(sessionKey))
|
|
25
|
+
out.push(`agent:main:${sessionKey}`);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function resolveActiveSessionId(sessionKey) {
|
|
29
|
+
for (const key of sessionKeyCandidates(sessionKey)) {
|
|
30
|
+
const sessionId = resolveActiveEmbeddedRunSessionId(key);
|
|
31
|
+
if (sessionId)
|
|
32
|
+
return { sessionId, resolvedKey: key };
|
|
33
|
+
}
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
export default definePluginEntry({
|
|
37
|
+
id: "lma-steer",
|
|
38
|
+
name: "LMA Steer",
|
|
39
|
+
description: "Steer a queued message into the active OpenClaw run at the next tool-call boundary, with an observable outcome for the LMA bridge.",
|
|
40
|
+
register(api) {
|
|
41
|
+
api.registerGatewayMethod("lma.steer", async ({ params, respond }) => {
|
|
42
|
+
const sessionKey = readString(params.sessionKey);
|
|
43
|
+
const text = readString(params.text);
|
|
44
|
+
if (!sessionKey) {
|
|
45
|
+
respond(false, undefined, { code: ERR_INVALID, message: "lma.steer requires a non-empty sessionKey" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!text) {
|
|
49
|
+
respond(false, undefined, { code: ERR_INVALID, message: "lma.steer requires a non-empty text" });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// A run may be accepted (chat.send returned a runId) but still in its
|
|
54
|
+
// cold-start/queued window where it is not yet resolvable or streaming.
|
|
55
|
+
// The LMA bridge only calls steer when it believes a run is active, so
|
|
56
|
+
// briefly wait for the run to become steerable instead of giving up on
|
|
57
|
+
// the first miss. Total wait is bounded so we never hang the caller.
|
|
58
|
+
// We also try both the bare and agent:main:-qualified session key, since
|
|
59
|
+
// the bridge passes the bare key while runs register under the qualified one.
|
|
60
|
+
const deadline = Date.now() + STEER_WAIT_MS;
|
|
61
|
+
let { sessionId, resolvedKey } = resolveActiveSessionId(sessionKey);
|
|
62
|
+
let waited = 0;
|
|
63
|
+
while (!sessionId && Date.now() < deadline) {
|
|
64
|
+
await sleep(STEER_POLL_MS);
|
|
65
|
+
waited += STEER_POLL_MS;
|
|
66
|
+
({ sessionId, resolvedKey } = resolveActiveSessionId(sessionKey));
|
|
67
|
+
}
|
|
68
|
+
if (!sessionId) {
|
|
69
|
+
console.log(`[lma-steer] no_active_run for ${sessionKey} after ${waited}ms wait`);
|
|
70
|
+
const result = { status: "no_active_run" };
|
|
71
|
+
respond(true, result);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Queue the message into the active run. queueAgentHarnessMessage only
|
|
75
|
+
// succeeds once the run is actually streaming; if the run resolved but is
|
|
76
|
+
// not streaming yet, retry within the remaining budget.
|
|
77
|
+
let queued = queueAgentHarnessMessage(sessionId, text, { steeringMode: "all" });
|
|
78
|
+
while (!queued && Date.now() < deadline) {
|
|
79
|
+
await sleep(STEER_POLL_MS);
|
|
80
|
+
waited += STEER_POLL_MS;
|
|
81
|
+
const re = resolveActiveSessionId(sessionKey);
|
|
82
|
+
sessionId = re.sessionId || sessionId;
|
|
83
|
+
queued = queueAgentHarnessMessage(sessionId, text, { steeringMode: "all" });
|
|
84
|
+
}
|
|
85
|
+
console.log(`[lma-steer] ${queued ? "steered" : "rejected"} ${sessionKey} (resolved=${resolvedKey}) sid=${sessionId} after ${waited}ms`);
|
|
86
|
+
const result = {
|
|
87
|
+
status: queued ? "steered" : "rejected",
|
|
88
|
+
sessionId,
|
|
89
|
+
};
|
|
90
|
+
respond(true, result);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
respond(false, undefined, {
|
|
94
|
+
code: "INTERNAL",
|
|
95
|
+
message: `lma.steer failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "lma-steer",
|
|
3
|
+
"name": "LMA Steer",
|
|
4
|
+
"description": "Inject (steer) a queued user message into the active OpenClaw run at the next tool-call boundary, with an observable outcome the LMA bridge can render as a Feishu reaction.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"activation": {
|
|
7
|
+
"onStartup": true
|
|
8
|
+
},
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lma-steer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"description": "OpenClaw plugin: steer a queued message into the active run at the next tool-call boundary (used by the LMA bridge).",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"openclaw": {
|
|
13
|
+
"extensions": ["./dist/index.js"],
|
|
14
|
+
"compat": {
|
|
15
|
+
"pluginApi": ">=2026.3.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|