openclaw-lark-multi-agent 1.3.0 → 1.4.2

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 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
- openclaw-lark-multi-agent init
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
- openclaw-lark-multi-agent start
87
+ lma start
85
88
  ```
86
89
 
87
90
  Install as a systemd user service:
88
91
 
89
92
  ```bash
90
- openclaw-lark-multi-agent install-systemd --user
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
- openclaw-lark-multi-agent install-windows-service
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
- openclaw-lark-multi-agent --help
103
- openclaw-lark-multi-agent doctor
104
- openclaw-lark-multi-agent start [config]
105
- openclaw-lark-multi-agent init [--state-dir DIR] [--force]
106
- openclaw-lark-multi-agent install-systemd [--user|--system] [--state-dir DIR]
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
- openclaw-lark-multi-agent init
218
+ lma init
198
219
  notepad $env:USERPROFILE\.openclaw\openclaw-lark-multi-agent\config.json
199
- openclaw-lark-multi-agent start
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
- openclaw-lark-multi-agent install-windows-service
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
- ${APP_NAME} start [config]
17
- ${APP_NAME} init [--state-dir DIR] [--force]
18
- ${APP_NAME} install-systemd [--user|--system] [--state-dir DIR] [--no-restart]
19
- ${APP_NAME} install-windows-service [--state-dir DIR] [--no-start]
20
- ${APP_NAME} doctor [--state-dir DIR]
21
- ${APP_NAME} --help
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
- ${APP_NAME} init
25
- ${APP_NAME} start ~/.openclaw/${APP_NAME}/config.json
26
- ${APP_NAME} install-systemd --user
27
- ${APP_NAME} install-windows-service
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
  }
@@ -114,7 +123,12 @@ WantedBy=${params.mode === "system" ? "multi-user.target" : "default.target"}
114
123
  `;
115
124
  }
116
125
  function run(cmd, args) {
117
- execFileSync(cmd, args, { stdio: "inherit" });
126
+ // On Windows, npm-installed CLIs (like `openclaw`) are .cmd shims. Node's
127
+ // execFileSync does NOT resolve/execute .cmd files without shell:true (it
128
+ // fails with ENOENT before the subprocess ever starts — no output from the
129
+ // target command at all). Native .exe tools (nssm, systemctl on Linux) are
130
+ // unaffected by this flag either way, so enabling it only on win32 is safe.
131
+ execFileSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
118
132
  }
119
133
  function runSudo(args) {
120
134
  run("sudo", args);
@@ -172,6 +186,72 @@ function cmdInstallWindowsService(args) {
172
186
  run("nssm", ["start", APP_NAME]);
173
187
  console.log(`Installed Windows service: ${APP_NAME}`);
174
188
  }
189
+ function runCapture(cmd, args) {
190
+ try {
191
+ // See run()'s comment: Windows needs shell:true to execute a .cmd shim
192
+ // (e.g. openclaw installed via `npm install -g`), otherwise the subprocess
193
+ // never starts (ENOENT) and no output from it is ever produced.
194
+ execFileSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
195
+ return { code: 0 };
196
+ }
197
+ catch (err) {
198
+ const code = typeof err?.status === "number" ? err.status : 1;
199
+ // A spawn failure (command never started) has no numeric exit status/signal
200
+ // from the target process — surface that distinctly from "the command ran
201
+ // and returned non-zero", since the fix differs (PATH vs. the tool's own error).
202
+ const spawnError = typeof err?.status !== "number" && !err?.signal
203
+ ? (err?.message || String(err))
204
+ : undefined;
205
+ return { code, spawnError };
206
+ }
207
+ }
208
+ /**
209
+ * Install the bundled lma-steer OpenClaw plugin into the local OpenClaw gateway.
210
+ * The plugin registers the `lma.steer` gateway method that the bridge uses to
211
+ * inject a message into an active run at the next tool-call boundary. The plugin
212
+ * ships inside this package under plugins/lma-steer (built to dist).
213
+ */
214
+ function cmdInstallSteerPlugin(args) {
215
+ // Default to --force so reinstalling an updated bundled plugin overwrites the
216
+ // previously installed copy. Pass --no-force to keep an existing install.
217
+ const noForce = hasFlag(args, "--no-force");
218
+ hasFlag(args, "--force");
219
+ if (args.length > 0)
220
+ throw new Error(`Unknown install-steer-plugin arguments: ${args.join(" ")}`);
221
+ // Resolve the bundled plugin dir relative to this CLI file: dist/cli.js -> ../plugins/lma-steer.
222
+ const cliDir = dirname(fileURLToPath(import.meta.url));
223
+ const pluginDir = resolve(cliDir, "..", "plugins", "lma-steer");
224
+ if (!existsSync(resolve(pluginDir, "openclaw.plugin.json")) || !existsSync(resolve(pluginDir, "dist", "index.js"))) {
225
+ throw new Error(`bundled lma-steer plugin not found or not built at: ${pluginDir}`);
226
+ }
227
+ const openclawBin = process.env.OPENCLAW_BIN || "openclaw";
228
+ const installArgs = ["plugins", "install", pluginDir];
229
+ if (!noForce)
230
+ installArgs.push("--force");
231
+ console.log(`Installing lma-steer plugin from: ${pluginDir}`);
232
+ const res = runCapture(openclawBin, installArgs);
233
+ if (res.code !== 0) {
234
+ if (res.spawnError) {
235
+ // The subprocess never started (e.g. Windows failing to exec a .cmd shim
236
+ // without a shell, or the binary truly missing from PATH). This is
237
+ // distinct from "openclaw ran and returned an error" — no output above
238
+ // this line came from openclaw itself.
239
+ console.error(`\nCould not start "${openclawBin}": ${res.spawnError}`);
240
+ console.error(`Make sure the OpenClaw CLI is installed and on PATH (or set OPENCLAW_BIN to its full path),`);
241
+ }
242
+ else {
243
+ console.error(`\n"${openclawBin} plugins install" failed (exit ${res.code}).`);
244
+ console.error(`Make sure the OpenClaw CLI is installed and on PATH (or set OPENCLAW_BIN),`);
245
+ }
246
+ console.error(`then rerun: ${CLI_NAME} install-steer-plugin`);
247
+ process.exit(res.code);
248
+ }
249
+ console.log(`\nlma-steer plugin installed. Restart the OpenClaw gateway to load it, e.g.:`);
250
+ console.log(` systemctl --user restart openclaw-gateway.service`);
251
+ console.log(`Then verify:`);
252
+ console.log(` openclaw gateway call lma.steer --params '{"sessionKey":"test","text":"ping"}'`);
253
+ console.log(` (expect {"status":"no_active_run"} for an idle session)`);
254
+ }
175
255
  function cmdDoctor(args) {
176
256
  const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
177
257
  if (args.length > 0)
@@ -194,6 +274,8 @@ async function main() {
194
274
  return cmdInstallSystemd(args);
195
275
  if (cmd === "install-windows-service")
196
276
  return cmdInstallWindowsService(args);
277
+ if (cmd === "install-steer-plugin")
278
+ return cmdInstallSteerPlugin(args);
197
279
  if (cmd === "doctor")
198
280
  return cmdDoctor(args);
199
281
  if (cmd === "start") {
@@ -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); }
@@ -900,15 +911,35 @@ export class FeishuBot {
900
911
  // seen at the next tool-call boundary (lets the user nudge/correct a long
901
912
  // run in real time). The lma-steer plugin reports whether it landed.
902
913
  const sessionKeyForSteer = this.getSessionKey(chatId);
903
- const steer = await this.openclawClient.steer(sessionKeyForSteer, cleanText).catch(() => ({ status: "unavailable" }));
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" }));
904
918
  if (steer.status === "steered") {
905
- // Inserted into the active run. Reuse the existing reaction lifecycle:
906
- // mark "Get" (received/inserted); the current run's completion will
907
- // advance it to DONE together with the rest of the batch.
908
- await this.addReaction(messageId, "Get").catch(() => { });
909
- pending.push({ messageId, emoji: "Get", rowId: insertedId });
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 });
910
926
  this.pendingAckMessages.set(chatId, pending);
911
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, STEERED into active run: "${cleanText.substring(0, 50)}..."`);
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)}..."`);
912
943
  // The message is already in DB with a pending trigger. Clear that
913
944
  // trigger so it is NOT re-run as a separate follow-up batch after the
914
945
  // current run ends — it has already been injected into this run.
@@ -2247,7 +2278,8 @@ export class FeishuBot {
2247
2278
  : kind === "tool_end" ? "✓"
2248
2279
  : kind === "lifecycle" ? "⋯"
2249
2280
  : kind === "summary" ? "📊"
2250
- : "•";
2281
+ : kind === "steer" ? "💬"
2282
+ : "•";
2251
2283
  const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
2252
2284
  const content = view.lines
2253
2285
  .map((l) => {
@@ -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;
@@ -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;
@@ -129,10 +136,30 @@ export declare class OpenClawClient {
129
136
  * - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
130
137
  * installed or gateway not restarted); caller falls back.
131
138
  */
132
- steer(sessionKey: string, text: string): Promise<{
139
+ steer(sessionKey: string, text: string, displayText?: string): Promise<{
133
140
  status: "steered" | "no_active_run" | "rejected" | "unavailable";
134
141
  sessionId?: string;
135
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;
136
163
  /**
137
164
  * Get session info (model, tokens, etc.) for status display.
138
165
  */
@@ -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))) {
@@ -933,11 +984,18 @@ export class OpenClawClient {
933
984
  * - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
934
985
  * installed or gateway not restarted); caller falls back.
935
986
  */
936
- async steer(sessionKey, text) {
987
+ async steer(sessionKey, text, displayText) {
937
988
  try {
938
989
  const res = await this.rpc("lma.steer", { sessionKey, text }, 10000);
939
990
  const status = res?.status;
940
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);
941
999
  return { status, sessionId: typeof res?.sessionId === "string" ? res.sessionId : undefined };
942
1000
  }
943
1001
  return { status: "unavailable" };
@@ -952,6 +1010,90 @@ export class OpenClawClient {
952
1010
  return { status: "unavailable" };
953
1011
  }
954
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
+ }
955
1097
  /**
956
1098
  * Get session info (model, tokens, etc.) for status display.
957
1099
  */
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.3.0",
3
+ "version": "1.4.2",
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
+ }