openclaw-lark-multi-agent 1.4.4 → 1.4.6

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.
@@ -3,8 +3,21 @@ import { randomUUID } from "crypto";
3
3
  import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
4
  import { basename, extname, join, resolve, sep } from "path";
5
5
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
6
- export const GATEWAY_PROTOCOL_MIN = 3;
6
+ export const GATEWAY_PROTOCOL_MIN = 4;
7
7
  export const GATEWAY_PROTOCOL_MAX = 4;
8
+ const REQUIRED_GATEWAY_METHODS = [
9
+ "agent.wait",
10
+ "chat.abort",
11
+ "chat.send",
12
+ "sessions.compact",
13
+ "sessions.create",
14
+ "sessions.delete",
15
+ "sessions.describe",
16
+ "sessions.messages.subscribe",
17
+ "sessions.patch",
18
+ "sessions.reset",
19
+ ];
20
+ const REQUIRED_GATEWAY_EVENTS = ["agent", "chat", "session.message"];
8
21
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
9
22
  const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
10
23
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
@@ -28,11 +41,14 @@ export class OpenClawClient {
28
41
  /** Callbacks for tool events (verbose mode) */
29
42
  toolEventCallbacks = new Map();
30
43
  lastToolNamesByItemId = new Map();
44
+ /** Deduplicates 2026.8 canonical `agent.stream=tool` events against legacy
45
+ * `stream=item` mirrors when a runtime emits both representations. */
46
+ recentToolEventKeys = new Map();
31
47
  sessionMessageCallbacks = new Map();
32
48
  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. */
49
+ /** Per-session steered messages awaiting consumption confirmation. Each
50
+ * accepted message is retained independently so identical/overlapping text
51
+ * cannot overwrite another correlation. */
36
52
  pendingSteerTexts = new Map();
37
53
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
38
54
  verboseTranscriptSessions = new Set();
@@ -45,9 +61,11 @@ export class OpenClawClient {
45
61
  /** Session keys whose transcript/session.message updates are currently suppressed. */
46
62
  suppressedSessions = new Set();
47
63
  suppressedSessionTimers = new Map();
48
- /** Session keys whose delivery is owned by this bridge's chatSend final path. */
49
- ownedDeliverySessions = new Set();
50
- ownedDeliverySessionTimers = new Map();
64
+ /** Run ids whose final delivery is owned by chatSend/collectReply. Delivery
65
+ * ownership must be run-scoped: a concurrent queueMode=steer admission may
66
+ * fall through to a separate ordinary run in the same session. */
67
+ ownedDeliveryRuns = new Set();
68
+ ownedDeliveryRunTimers = new Map();
51
69
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
52
70
  mutedProactiveSessions = new Set();
53
71
  mutedProactiveSessionCounts = new Map();
@@ -105,19 +123,47 @@ export class OpenClawClient {
105
123
  },
106
124
  role: "operator",
107
125
  scopes: ["operator.read", "operator.write", "operator.admin"],
126
+ caps: ["tool-events"],
108
127
  auth: { token: this.config.token },
109
128
  userAgent: "openclaw-lark-multi-agent/1.0.0",
110
129
  },
111
130
  }));
112
131
  }
113
132
  else if (frame.type === "res" && frame.ok && frame.payload?.type === "hello-ok") {
133
+ const negotiatedProtocol = Number(frame.payload.protocol);
134
+ if (negotiatedProtocol !== GATEWAY_PROTOCOL_MAX) {
135
+ reject(new Error(`Unsupported Gateway protocol ${frame.payload.protocol}; LMA requires protocol ${GATEWAY_PROTOCOL_MAX}`));
136
+ this.ws?.close();
137
+ return;
138
+ }
139
+ const advertisedMethods = Array.isArray(frame.payload.features?.methods)
140
+ ? new Set(frame.payload.features.methods)
141
+ : null;
142
+ if (advertisedMethods) {
143
+ const missing = REQUIRED_GATEWAY_METHODS.filter((method) => !advertisedMethods.has(method));
144
+ if (missing.length > 0) {
145
+ reject(new Error(`Gateway is missing required methods: ${missing.join(", ")}`));
146
+ this.ws?.close();
147
+ return;
148
+ }
149
+ }
150
+ const advertisedEvents = Array.isArray(frame.payload.features?.events)
151
+ ? new Set(frame.payload.features.events)
152
+ : null;
153
+ if (advertisedEvents) {
154
+ const missing = REQUIRED_GATEWAY_EVENTS.filter((event) => !advertisedEvents.has(event));
155
+ if (missing.length > 0) {
156
+ reject(new Error(`Gateway is missing required events: ${missing.join(", ")}`));
157
+ this.ws?.close();
158
+ return;
159
+ }
160
+ }
114
161
  handshakeDone = true;
115
162
  this.connected = true;
116
163
  console.log("[OpenClaw] Connected to Gateway WS");
117
164
  // Re-subscribe all previously subscribed sessions
118
165
  for (const key of this.subscribedKeys) {
119
- this.rpc("sessions.messages.subscribe", { key }).catch(() => { });
120
- this.rpc("sessions.messages.subscribe", { key: `agent:main:${key}` }).catch(() => { });
166
+ this.rpc("sessions.messages.subscribe", { key: this.canonicalSessionKey(key) }).catch(() => { });
121
167
  }
122
168
  resolve();
123
169
  }
@@ -232,7 +278,7 @@ export class OpenClawClient {
232
278
  data: { deltaText: visibleAssistantText, delta: visibleAssistantText, replace: true },
233
279
  });
234
280
  }
235
- this.handleProactiveSessionMessage(rawKey, msg);
281
+ this.handleProactiveSessionMessage(rawKey, msg, frame.payload.runId);
236
282
  // Tool calls in assistant messages — skip, using agent item events instead
237
283
  // (session.message toolCall events are batched, not real-time)
238
284
  }
@@ -261,15 +307,53 @@ export class OpenClawClient {
261
307
  this.clearVerboseAssistantState(rawKey);
262
308
  }
263
309
  if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
264
- const phase = data.phase || "event";
265
- const meta = data.meta || data.input || data.args || "";
310
+ const phase = data.phase;
311
+ const eventId = String(data.toolCallId || data.itemId || data.id || data.name);
312
+ if (this.claimToolEvent(rawKey, eventId, phase)) {
313
+ const meta = data.meta || data.input || data.args || "";
314
+ const output = data.output || data.result || data.error || "";
315
+ const inputText = this.formatToolValue(meta, String(data.name), "start");
316
+ const outputText = this.formatToolValue(output, String(data.name), phase);
317
+ if (toolCb)
318
+ toolCb(`${data.name} ${phase}`.trim(), inputText, outputText);
319
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
320
+ if (progressCb) {
321
+ const detail = phase === "start" ? inputText : (outputText || inputText);
322
+ void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
323
+ }
324
+ }
325
+ }
326
+ }
327
+ // OpenClaw 2026.8 canonical tool stream. Some runtimes also emit the
328
+ // legacy item mirror, so claimToolEvent suppresses duplicate start/end.
329
+ if (frame.event === "agent" && frame.payload?.stream === "tool") {
330
+ const data = frame.payload.data || {};
331
+ const rawKey = frame.payload.sessionKey || "";
332
+ const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
333
+ const rawPhase = String(data.phase || "");
334
+ const phase = rawPhase === "start"
335
+ ? "start"
336
+ : rawPhase === "result"
337
+ ? (data.isError || data.error ? "error" : "end")
338
+ : undefined;
339
+ const name = String(data.name || data.toolName || "tool");
340
+ const eventId = String(data.toolCallId || data.itemId || data.id || name);
341
+ if (phase && this.claimToolEvent(rawKey, eventId, phase)) {
342
+ if (phase === "start") {
343
+ this.flushVerboseAssistantState(rawKey);
344
+ this.clearVerboseAssistantState(rawKey);
345
+ }
346
+ const input = data.input || data.args || data.meta || "";
266
347
  const output = data.output || data.result || data.error || "";
348
+ const inputText = this.formatToolValue(input, name, "start");
349
+ const outputText = this.formatToolValue(output, name, phase);
350
+ const toolCb = this.toolEventCallbacks.get(rawKey) || this.toolEventCallbacks.get(shortKey);
267
351
  if (toolCb)
268
- toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
352
+ toolCb(`${name} ${phase}`, inputText, outputText);
269
353
  const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
270
354
  if (progressCb) {
271
- const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
272
- void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
355
+ const detail = phase === "start" ? inputText : (outputText || inputText);
356
+ void progressCb({ kind: "tool", phase, name, text: `${name} ${phase}${detail ? `: ${detail}` : ""}` });
273
357
  }
274
358
  }
275
359
  }
@@ -371,6 +455,91 @@ export class OpenClawClient {
371
455
  this.verboseAssistantLastTouched.delete(key);
372
456
  }
373
457
  }
458
+ /** Render protocol-4 structured tool args/results without `[object Object]`.
459
+ * Prefer useful, non-secret fields and keep the fallback bounded. */
460
+ formatToolValue(value, toolName, phase) {
461
+ if (value === null || value === undefined || value === "")
462
+ return "";
463
+ if (typeof value === "string")
464
+ return value.trim();
465
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
466
+ return String(value);
467
+ if (Array.isArray(value)) {
468
+ const items = value.slice(0, 4).map((item) => this.formatToolValue(item, toolName, phase)).filter(Boolean);
469
+ return items.join("; ");
470
+ }
471
+ if (typeof value !== "object")
472
+ return String(value);
473
+ const record = value;
474
+ const read = (key) => this.formatToolValue(record[key], toolName, phase);
475
+ const first = (...keys) => {
476
+ for (const key of keys) {
477
+ if (!(key in record))
478
+ continue;
479
+ const text = read(key);
480
+ if (text)
481
+ return text;
482
+ }
483
+ return "";
484
+ };
485
+ // Tool-specific start summaries put the primary operation first.
486
+ if (phase === "start") {
487
+ if (toolName === "exec") {
488
+ const command = first("command", "cmd", "script");
489
+ const cwd = first("workdir", "cwd");
490
+ if (command)
491
+ return cwd ? `${command} (cwd: ${cwd})` : command;
492
+ }
493
+ if (toolName === "read" || toolName === "write" || toolName === "edit" || toolName === "apply_patch") {
494
+ const path = first("path", "file", "filename");
495
+ if (path)
496
+ return path;
497
+ }
498
+ if (toolName === "web_fetch") {
499
+ const url = first("url");
500
+ if (url)
501
+ return url;
502
+ }
503
+ if (toolName === "web_search") {
504
+ const query = first("query");
505
+ if (query)
506
+ return query;
507
+ }
508
+ }
509
+ const primary = phase === "start"
510
+ ? first("command", "path", "url", "query", "action", "target", "sessionKey", "key", "name", "description", "text")
511
+ : first("error", "message", "status", "exitCode", "output", "stdout", "stderr", "text", "result");
512
+ if (primary)
513
+ return primary;
514
+ // Safe generic fallback: omit fields that commonly carry credentials or
515
+ // large payloads, then serialize a few scalar fields only.
516
+ const blocked = /token|secret|password|auth|credential|cookie|buffer|content|data|env/i;
517
+ const parts = [];
518
+ for (const [key, raw] of Object.entries(record)) {
519
+ if (blocked.test(key) || raw === null || raw === undefined || typeof raw === "object")
520
+ continue;
521
+ parts.push(`${key}=${String(raw)}`);
522
+ if (parts.length >= 4)
523
+ break;
524
+ }
525
+ return parts.join(", ");
526
+ }
527
+ claimToolEvent(sessionKey, eventId, phase, now = Date.now()) {
528
+ const normalizedPhase = phase === "error" ? "end" : phase;
529
+ const normalizedId = eventId.replace(/^(?:tool|command):/, "");
530
+ const key = `${this.shortKey(sessionKey)}:${normalizedId}:${normalizedPhase}`;
531
+ const seenAt = this.recentToolEventKeys.get(key);
532
+ if (seenAt !== undefined && now - seenAt < 60_000)
533
+ return false;
534
+ this.recentToolEventKeys.set(key, now);
535
+ if (this.recentToolEventKeys.size > 2000) {
536
+ for (const [candidate, ts] of this.recentToolEventKeys) {
537
+ if (now - ts > 60_000)
538
+ this.recentToolEventKeys.delete(candidate);
539
+ }
540
+ }
541
+ return true;
542
+ }
374
543
  pruneVerboseCaches(now = Date.now()) {
375
544
  for (const [key, ts] of this.verboseAssistantLastTouched) {
376
545
  if (now - ts > 10 * 60_000) {
@@ -388,6 +557,10 @@ export class OpenClawClient {
388
557
  for (const key of Array.from(this.lastToolNamesByItemId.keys()).slice(0, overflow))
389
558
  this.lastToolNamesByItemId.delete(key);
390
559
  }
560
+ for (const [key, ts] of this.recentToolEventKeys) {
561
+ if (now - ts > 60_000)
562
+ this.recentToolEventKeys.delete(key);
563
+ }
391
564
  }
392
565
  flushVerboseAssistantState(rawKey) {
393
566
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
@@ -444,7 +617,7 @@ export class OpenClawClient {
444
617
  this.flushVerboseAssistantState(rawKey);
445
618
  }
446
619
  }
447
- handleProactiveSessionMessage(rawKey, msg) {
620
+ handleProactiveSessionMessage(rawKey, msg, runId) {
448
621
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
449
622
  // Proactive assistant text messages. Cron/session-targeted runs often emit
450
623
  // structured content arrays rather than a plain string. Extract only visible
@@ -454,6 +627,10 @@ export class OpenClawClient {
454
627
  const proactiveText = this.extractVisibleAssistantText(msg, { allowMixedToolText: allowVerboseTranscript });
455
628
  if (!proactiveText)
456
629
  return false;
630
+ if (runId && this.ownedDeliveryRuns.has(runId)) {
631
+ console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; run delivery is owned by chatSend`);
632
+ return false;
633
+ }
457
634
  if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
458
635
  console.log(`[OpenClaw] Dropping proactive msg for ${shortKey}; delivery is owned by the caller`);
459
636
  return false;
@@ -463,8 +640,12 @@ export class OpenClawClient {
463
640
  return false;
464
641
  }
465
642
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
466
- if (cb)
467
- cb(proactiveText);
643
+ if (cb) {
644
+ if (runId)
645
+ cb(proactiveText, { runId });
646
+ else
647
+ cb(proactiveText);
648
+ }
468
649
  return Boolean(cb);
469
650
  }
470
651
  scheduleReconnect() {
@@ -998,7 +1179,7 @@ export class OpenClawClient {
998
1179
  : 0;
999
1180
  const elapsed = elapsedSeconds > 0 ? `(运行约 ${this.formatDurationSeconds(elapsedSeconds)})` : "";
1000
1181
  const phase = timeoutPhase ? `,阶段:${timeoutPhase}` : "";
1001
- return `Agent 运行超时${elapsed}${phase},已被 OpenClaw 终止`;
1182
+ return `等待 Agent 运行结果超时${elapsed}${phase};底层运行可能仍在继续`;
1002
1183
  }
1003
1184
  if (rawError)
1004
1185
  return rawError;
@@ -1035,53 +1216,51 @@ export class OpenClawClient {
1035
1216
  }
1036
1217
  // --- Session management ---
1037
1218
  async createSession(params) {
1038
- return this.rpc("sessions.create", params);
1219
+ return this.rpc("sessions.create", { ...params, key: this.canonicalSessionKey(params.key) });
1039
1220
  }
1040
1221
  async patchSession(params) {
1041
- return this.rpc("sessions.patch", params, 10000);
1222
+ return this.rpc("sessions.patch", { ...params, key: this.canonicalSessionKey(params.key) }, 10000);
1042
1223
  }
1043
1224
  async getSessionStatus(key) {
1044
- return this.rpc("sessions.describe", { key });
1225
+ return this.rpc("sessions.describe", { key: this.canonicalSessionKey(key) });
1045
1226
  }
1046
1227
  async injectAssistantMessage(params) {
1047
- return this.rpc("chat.inject", params, 10000);
1228
+ return this.rpc("chat.inject", { ...params, sessionKey: this.canonicalSessionKey(params.sessionKey) }, 10000);
1048
1229
  }
1049
1230
  /**
1050
- * Steer a message into the ACTIVE run for a session via the lma-steer plugin's
1051
- * `lma.steer` gateway method. Unlike a plain new message, this injects the text
1052
- * at the next tool-call boundary of the run that is currently executing, so the
1053
- * user can nudge/correct a long run instead of waiting for it to finish.
1054
- *
1055
- * Returns a structured status so the bridge can render the right reaction:
1056
- * - "steered" -> queued into the active run (will be seen next boundary)
1057
- * - "no_active_run" -> no active run; caller should fall back to normal queueing
1058
- * - "rejected" -> active run refused the injection (compacting/not streaming)
1059
- * - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
1060
- * installed or gateway not restarted); caller falls back.
1231
+ * Submit a mid-run message through OpenClaw's public atomic steer path.
1232
+ * OpenClaw 2026.8 deprecated the synchronous plugin primitive previously used
1233
+ * by `lma-steer`: its boolean only represented immediate eligibility and could
1234
+ * still be rejected asynchronously. `chat.send queueMode=steer` owns admission
1235
+ * and transcript persistence. LMA still treats the RPC result as provisional;
1236
+ * the Feishu reaction flips to Get only after a matching `session.message`
1237
+ * confirms that the input was committed/consumed.
1061
1238
  */
1062
1239
  async steer(sessionKey, text, displayText) {
1240
+ const key = this.canonicalSessionKey(sessionKey);
1063
1241
  try {
1064
- const res = await this.rpc("lma.steer", { sessionKey, text }, 10000);
1242
+ const res = await this.rpc("chat.send", {
1243
+ sessionKey: key,
1244
+ message: text,
1245
+ queueMode: "steer",
1246
+ deliver: false,
1247
+ idempotencyKey: randomUUID(),
1248
+ }, 10000);
1065
1249
  const status = res?.status;
1066
- if (status === "steered" || status === "no_active_run" || status === "rejected") {
1067
- // On a successful steer, remember the text so that when the model
1068
- // actually consumes it (a matching sessionUser event on THIS run) we can
1069
- // render it on the live-status card in correct time order and let the
1070
- // bridge flip the reaction to Get only after real consumption. Match on
1071
- // the (possibly wrapped) text actually sent; display the original.
1072
- if (status === "steered")
1073
- this.registerPendingSteer(sessionKey, text, displayText);
1074
- return { status, sessionId: typeof res?.sessionId === "string" ? res.sessionId : undefined };
1250
+ if (status !== "started" && status !== "in_flight" && status !== "ok") {
1251
+ return { status: "unavailable" };
1075
1252
  }
1076
- return { status: "unavailable" };
1253
+ // This records only a provisional accepted input. Actual success is driven
1254
+ // by handleSteerConsumption after the canonical transcript event arrives.
1255
+ const cancelPending = this.registerPendingSteer(key, text, displayText);
1256
+ return {
1257
+ status: "steered",
1258
+ runId: typeof res?.runId === "string" ? res.runId : undefined,
1259
+ cancelPending,
1260
+ };
1077
1261
  }
1078
1262
  catch (err) {
1079
- // Unknown method / plugin missing / transient RPC error -> caller falls back
1080
- // to normal queueing. Never throw into the message path.
1081
- const msg = err?.message || String(err);
1082
- if (!/unknown method/i.test(msg)) {
1083
- console.warn(`[OpenClaw] lma.steer failed for ${sessionKey.slice(-8)}: ${msg}`);
1084
- }
1263
+ console.warn(`[OpenClaw] native steer failed for ${key.slice(-8)}: ${err?.message || String(err)}`);
1085
1264
  return { status: "unavailable" };
1086
1265
  }
1087
1266
  }
@@ -1095,29 +1274,71 @@ export class OpenClawClient {
1095
1274
  * is actually consumed by the active run. The bridge uses this to flip the
1096
1275
  * Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
1097
1276
  * Return true from the callback to consume/remove it (one-shot). */
1098
- onSteerConsumed(sessionKey, cb) {
1277
+ onSteerConsumed(sessionKey, matchText, cb) {
1099
1278
  const key = this.shortKey(sessionKey);
1279
+ const normalizedMatch = matchText.replace(/\s+/g, " ").trim();
1100
1280
  let list = this.steerConsumedCallbacks.get(key);
1101
1281
  if (!list) {
1102
1282
  list = [];
1103
1283
  this.steerConsumedCallbacks.set(key, list);
1104
1284
  }
1105
- list.push(cb);
1285
+ const entry = { matchText: normalizedMatch, cb };
1286
+ list.push(entry);
1287
+ let active = true;
1288
+ const remove = () => {
1289
+ if (!active)
1290
+ return;
1291
+ active = false;
1292
+ clearTimeout(expiry);
1293
+ const current = this.steerConsumedCallbacks.get(key);
1294
+ if (!current)
1295
+ return;
1296
+ const next = current.filter((candidate) => candidate !== entry);
1297
+ if (next.length > 0)
1298
+ this.steerConsumedCallbacks.set(key, next);
1299
+ else
1300
+ this.steerConsumedCallbacks.delete(key);
1301
+ };
1302
+ const expiry = setTimeout(remove, 30 * 60_000);
1303
+ expiry.unref?.();
1304
+ return remove;
1106
1305
  }
1107
1306
  shortKey(sessionKey) {
1108
1307
  return sessionKey.replace(/^agent:[^:]+:/, "");
1109
1308
  }
1309
+ canonicalSessionKey(sessionKey) {
1310
+ return /^agent:[^:]+:/.test(sessionKey) ? sessionKey : `agent:main:${sessionKey}`;
1311
+ }
1110
1312
  registerPendingSteer(sessionKey, text, displayText) {
1111
1313
  const key = this.shortKey(sessionKey);
1112
- const norm = text.replace(/\s+/g, " ").trim();
1113
- if (!norm)
1114
- return;
1115
- let map = this.pendingSteerTexts.get(key);
1116
- if (!map) {
1117
- map = new Map();
1118
- this.pendingSteerTexts.set(key, map);
1119
- }
1120
- map.set(norm, (displayText || text).trim());
1314
+ const submittedText = text.replace(/\s+/g, " ").trim();
1315
+ if (!submittedText)
1316
+ return () => { };
1317
+ const displayTextValue = (displayText || text).trim();
1318
+ const queue = this.pendingSteerTexts.get(key) || [];
1319
+ const entry = { submittedText, displayText: displayTextValue };
1320
+ queue.push(entry);
1321
+ this.pendingSteerTexts.set(key, queue);
1322
+ let active = true;
1323
+ const remove = () => {
1324
+ if (!active)
1325
+ return;
1326
+ active = false;
1327
+ clearTimeout(expiry);
1328
+ const current = this.pendingSteerTexts.get(key);
1329
+ if (!current)
1330
+ return;
1331
+ const next = current.filter((candidate) => candidate !== entry);
1332
+ if (next.length > 0)
1333
+ this.pendingSteerTexts.set(key, next);
1334
+ else
1335
+ this.pendingSteerTexts.delete(key);
1336
+ };
1337
+ // Expire only this provisional correlation. A much later transcript must
1338
+ // not consume a stale duplicate while another identical steer is pending.
1339
+ const expiry = setTimeout(remove, 30 * 60_000);
1340
+ expiry.unref?.();
1341
+ return remove;
1121
1342
  }
1122
1343
  /**
1123
1344
  * Called when a sessionUser event is seen for a session. If its text matches a
@@ -1127,44 +1348,49 @@ export class OpenClawClient {
1127
1348
  */
1128
1349
  handleSteerConsumption(sessionKey, text) {
1129
1350
  const key = this.shortKey(sessionKey);
1130
- const map = this.pendingSteerTexts.get(key);
1131
- if (!map || map.size === 0)
1351
+ const queue = this.pendingSteerTexts.get(key);
1352
+ if (!queue || queue.length === 0)
1132
1353
  return false;
1133
1354
  const norm = (text || "").replace(/\s+/g, " ").trim();
1134
1355
  if (!norm)
1135
1356
  return false;
1136
- // Match exact, or the sessionUser text containing the steered text (the run
1137
- // may wrap it). Prefer the longest matching pending entry.
1138
- let matched;
1139
- for (const pending of map.keys()) {
1140
- if (norm === pending || norm.includes(pending)) {
1141
- if (!matched || pending.length > matched.length)
1142
- matched = pending;
1357
+ // One transcript event consumes exactly one pending steer. Prefer an exact
1358
+ // match, otherwise the longest submitted wrapper contained in the event.
1359
+ let matchedIndex = queue.findIndex((pending) => norm === pending.submittedText);
1360
+ if (matchedIndex < 0) {
1361
+ let matchedLength = -1;
1362
+ for (let i = 0; i < queue.length; i++) {
1363
+ const pending = queue[i];
1364
+ if (norm.includes(pending.submittedText) && pending.submittedText.length > matchedLength) {
1365
+ matchedIndex = i;
1366
+ matchedLength = pending.submittedText.length;
1367
+ }
1143
1368
  }
1144
1369
  }
1145
- if (!matched)
1370
+ if (matchedIndex < 0)
1146
1371
  return false;
1147
- const display = map.get(matched) || matched;
1148
- map.delete(matched);
1149
- if (map.size === 0)
1372
+ const [matched] = queue.splice(matchedIndex, 1);
1373
+ if (queue.length === 0)
1150
1374
  this.pendingSteerTexts.delete(key);
1151
1375
  const progressCb = this.progressCallbacks.get(key) || this.progressCallbacks.get(`agent:main:${key}`);
1152
1376
  if (progressCb)
1153
- void progressCb({ kind: "steer", text: display });
1377
+ void progressCb({ kind: "steer", text: matched.displayText });
1154
1378
  const list = this.steerConsumedCallbacks.get(key);
1155
1379
  if (list && list.length > 0) {
1156
- // Invoke callbacks; a callback returning true is one-shot and removed.
1157
- const remaining = list.filter((cb) => {
1380
+ // Match and remove exactly one observer for this consumed steer. This is
1381
+ // important for duplicate text: one session.message must acknowledge one
1382
+ // Feishu message, never all identical messages at once.
1383
+ let callbackIndex = list.findIndex((entry) => entry.matchText === matched.submittedText);
1384
+ if (callbackIndex < 0)
1385
+ callbackIndex = list.findIndex((entry) => norm.includes(entry.matchText));
1386
+ if (callbackIndex >= 0) {
1387
+ const [entry] = list.splice(callbackIndex, 1);
1158
1388
  try {
1159
- return cb(matched) !== true;
1160
- }
1161
- catch {
1162
- return true;
1389
+ entry.cb(matched.submittedText);
1163
1390
  }
1164
- });
1165
- if (remaining.length > 0)
1166
- this.steerConsumedCallbacks.set(key, remaining);
1167
- else
1391
+ catch { /* observer failures are isolated */ }
1392
+ }
1393
+ if (list.length === 0)
1168
1394
  this.steerConsumedCallbacks.delete(key);
1169
1395
  }
1170
1396
  return true;
@@ -1173,41 +1399,57 @@ export class OpenClawClient {
1173
1399
  * Get session info (model, tokens, etc.) for status display.
1174
1400
  */
1175
1401
  async getSessionInfo(sessionKey) {
1176
- return this.rpc("sessions.describe", { key: sessionKey });
1402
+ return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
1177
1403
  }
1178
1404
  /**
1179
1405
  * Ensure session is using the expected model. If not, patch it back.
1180
1406
  * Returns true if a correction was made.
1181
1407
  */
1182
1408
  async ensureModel(sessionKey, expectedModel) {
1409
+ const info = await this.getSessionInfo(sessionKey).catch(() => null);
1410
+ const session = info?.session;
1411
+ const modelId = typeof session?.model === "string" ? session.model.trim() : "";
1412
+ const provider = typeof session?.modelProvider === "string"
1413
+ ? session.modelProvider.trim()
1414
+ : typeof session?.provider === "string" ? session.provider.trim() : "";
1415
+ const currentModel = modelId.includes("/") ? modelId : provider && modelId ? `${provider}/${modelId}` : modelId;
1416
+ if (currentModel === expectedModel)
1417
+ return false;
1183
1418
  try {
1184
- // Always patch to ensure model is correct — describe may return internal model names
1185
- // Use short timeout as sessions.patch may not return a response
1186
- await this.patchSession({ key: sessionKey, model: expectedModel }).catch(() => { });
1187
- // Also try with full key prefix
1188
- await this.patchSession({ key: `agent:main:${sessionKey}`, model: expectedModel }).catch(() => { });
1189
- console.log(`[OpenClaw] Model ensured: ${sessionKey} → ${expectedModel}`);
1419
+ await this.patchSession({ key: sessionKey, model: expectedModel });
1190
1420
  }
1191
- catch (err) {
1192
- console.warn(`[OpenClaw] ensureModel patch failed:`, err.message);
1421
+ catch (firstErr) {
1422
+ // Older gateways occasionally require the fully-qualified key. Do not hide
1423
+ // model-policy/capability errors: retry only when the key itself was not
1424
+ // resolved, otherwise surface the actionable rejection to the caller.
1425
+ const message = firstErr.message || String(firstErr);
1426
+ if (!/session (?:not found|unknown)|unknown session|invalid session key/i.test(message))
1427
+ throw firstErr;
1428
+ const fullKey = sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
1429
+ await this.patchSession({ key: fullKey, model: expectedModel });
1193
1430
  }
1194
- return false;
1431
+ console.log(`[OpenClaw] Model ensured: ${sessionKey} → ${expectedModel}`);
1432
+ return Boolean(currentModel && currentModel !== expectedModel);
1195
1433
  }
1196
1434
  async deleteSession(key, deleteTranscript = true) {
1197
- return this.rpc("sessions.delete", { key, deleteTranscript });
1435
+ return this.rpc("sessions.delete", { key: this.canonicalSessionKey(key), deleteTranscript });
1198
1436
  }
1199
1437
  async resetSession(key) {
1200
1438
  // sessions.reset may not return a response; use short timeout
1201
- return this.rpc("sessions.reset", { key }, 5000).catch(() => { });
1439
+ return this.rpc("sessions.reset", { key: this.canonicalSessionKey(key) }, 5000).catch(() => { });
1202
1440
  }
1203
- async compactSession(key) {
1441
+ async compactSession(key, options = {}) {
1204
1442
  const release = await this.acquireMaintenanceSlot("sessions.compact");
1205
1443
  const startedAt = Date.now();
1444
+ const maxLines = Number.isFinite(options.maxLines)
1445
+ ? Math.max(1, Math.floor(options.maxLines))
1446
+ : undefined;
1206
1447
  try {
1207
- return await this.rpc("sessions.compact", { key }, 10 * 60 * 1000);
1448
+ return await this.rpc("sessions.compact", { key: this.canonicalSessionKey(key), ...(maxLines !== undefined ? { maxLines } : {}) }, 10 * 60 * 1000);
1208
1449
  }
1209
1450
  finally {
1210
- console.log(`[OpenClaw] sessions.compact finished for ${key} in ${Date.now() - startedAt}ms`);
1451
+ const mode = maxLines === undefined ? "semantic" : `maxLines=${maxLines}`;
1452
+ console.log(`[OpenClaw] sessions.compact (${mode}) finished for ${key} in ${Date.now() - startedAt}ms`);
1211
1453
  release();
1212
1454
  }
1213
1455
  }
@@ -1217,50 +1459,50 @@ export class OpenClawClient {
1217
1459
  * deliver=false prevents OpenClaw from auto-posting to channels.
1218
1460
  */
1219
1461
  async abortChat(sessionKey, runId) {
1220
- const key = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
1462
+ const key = this.canonicalSessionKey(sessionKey);
1221
1463
  // chat.abort supports { sessionKey } with no runId to abort ALL active runs
1222
1464
  // for that session. Used by /stop to force-clear a stuck run.
1223
1465
  if (!runId) {
1224
1466
  // Mark the session so any in-flight collectReply finishes immediately
1225
1467
  // instead of treating the cancelled lifecycle as a transient state.
1226
1468
  this.forceAbortedSessions.add(key);
1227
- this.forceAbortedSessions.add(`agent:main:${key}`);
1469
+ this.forceAbortedSessions.add(this.shortKey(key));
1228
1470
  }
1229
1471
  const params = runId ? { sessionKey: key, runId } : { sessionKey: key };
1230
1472
  return this.rpc("chat.abort", params, 5000).catch(() => { });
1231
1473
  }
1232
1474
  sessionKeyVariants(key) {
1233
- const shortKey = key.startsWith("agent:main:") ? key.slice("agent:main:".length) : key;
1234
- return [shortKey, `agent:main:${shortKey}`];
1475
+ const shortKey = this.shortKey(key);
1476
+ return Array.from(new Set([shortKey, this.canonicalSessionKey(key), key]));
1235
1477
  }
1236
1478
  trackChatEventSession(sessionKey, state, payload) {
1237
1479
  if (!sessionKey || sessionKey === "__default__")
1238
1480
  return;
1239
1481
  const keys = this.sessionKeyVariants(sessionKey);
1240
- if (this.isOwnedDeliverySession(sessionKey)) {
1241
- // This chat event belongs to a bridge-owned chat.send run. The final answer
1242
- // is delivered by collectReply/processQueue, so transcript session.message
1243
- // mirrors must stay suppressed briefly.
1244
- this.suppressSessionKeys(keys);
1245
- if (state === "final" || state === "error" || state === "aborted")
1246
- this.releaseSuppressedSessionKeysAfter(keys, 30000);
1482
+ const runId = typeof payload?.runId === "string" ? payload.runId : "";
1483
+ if (runId && this.ownedDeliveryRuns.has(runId)) {
1484
+ // collectReply owns this exact run. Do not suppress the whole session:
1485
+ // queueMode=steer may concurrently create another ordinary run that must
1486
+ // still reach the proactive callback.
1247
1487
  return;
1248
1488
  }
1249
- // External WebChat/Control UI chat against an LMA session: do not forward
1250
- // streaming transcript updates, but allow the final chat message to be
1251
- // delivered through the proactive callback after it is committed.
1489
+ // External WebChat/Control UI (including a steer admission that became a
1490
+ // separate run): hide streaming mirrors, then emit its final exactly once.
1252
1491
  if (state === "delta") {
1253
1492
  this.suppressSessionKeys(keys);
1254
1493
  }
1255
1494
  else if (state === "final") {
1256
1495
  const text = this.extractTextFromChatMessage(payload?.message);
1257
1496
  if (text)
1258
- this.emitProactiveForSession(sessionKey, text);
1259
- // Keep transcript mirrors suppressed briefly; chat final already emitted
1260
- // the user-visible result for external WebChat/Control UI turns.
1497
+ this.emitProactiveForSession(sessionKey, text, runId);
1261
1498
  this.releaseSuppressedSessionKeysAfter(keys, 30000);
1262
1499
  }
1263
1500
  else if (state === "error" || state === "aborted") {
1501
+ const detail = String(payload?.errorMessage || payload?.error || payload?.reason || "").trim();
1502
+ const text = state === "error"
1503
+ ? `⚠️ Agent run failed${detail ? `: ${detail}` : ""}`
1504
+ : `⚠️ Agent run was aborted${detail ? `: ${detail}` : ""}`;
1505
+ this.emitProactiveForSession(sessionKey, text, runId, "run_error");
1264
1506
  this.releaseSuppressedSessionKeysAfter(keys, 0);
1265
1507
  }
1266
1508
  }
@@ -1268,12 +1510,15 @@ export class OpenClawClient {
1268
1510
  const parts = Array.isArray(message?.content) ? message.content : [];
1269
1511
  return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
1270
1512
  }
1271
- emitProactiveForSession(sessionKey, text) {
1513
+ emitProactiveForSession(sessionKey, text, runId, sourceType) {
1272
1514
  const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
1273
1515
  const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
1274
1516
  if (!cb)
1275
1517
  return false;
1276
- cb(text);
1518
+ if (runId || sourceType)
1519
+ cb(text, { ...(runId ? { runId } : {}), ...(sourceType ? { sourceType } : {}) });
1520
+ else
1521
+ cb(text);
1277
1522
  return true;
1278
1523
  }
1279
1524
  suppressSessionKeys(keys) {
@@ -1297,30 +1542,27 @@ export class OpenClawClient {
1297
1542
  this.suppressedSessionTimers.set(key, timer);
1298
1543
  }
1299
1544
  }
1300
- ownDeliverySessionKeys(keys) {
1301
- for (const key of keys) {
1302
- const timer = this.ownedDeliverySessionTimers.get(key);
1303
- if (timer)
1304
- clearTimeout(timer);
1305
- this.ownedDeliverySessionTimers.delete(key);
1306
- this.ownedDeliverySessions.add(key);
1307
- }
1308
- }
1309
- releaseOwnedDeliverySessionKeysAfter(keys, delayMs) {
1310
- for (const key of keys) {
1311
- const oldTimer = this.ownedDeliverySessionTimers.get(key);
1312
- if (oldTimer)
1313
- clearTimeout(oldTimer);
1314
- const timer = setTimeout(() => {
1315
- this.ownedDeliverySessions.delete(key);
1316
- this.ownedDeliverySessionTimers.delete(key);
1317
- }, delayMs);
1318
- this.ownedDeliverySessionTimers.set(key, timer);
1319
- }
1545
+ ownDeliveryRun(runId) {
1546
+ if (!runId)
1547
+ return;
1548
+ const timer = this.ownedDeliveryRunTimers.get(runId);
1549
+ if (timer)
1550
+ clearTimeout(timer);
1551
+ this.ownedDeliveryRunTimers.delete(runId);
1552
+ this.ownedDeliveryRuns.add(runId);
1320
1553
  }
1321
- isOwnedDeliverySession(sessionKey) {
1322
- const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
1323
- return this.ownedDeliverySessions.has(shortKey) || this.ownedDeliverySessions.has(fullKey);
1554
+ releaseOwnedDeliveryRunAfter(runId, delayMs) {
1555
+ if (!runId)
1556
+ return;
1557
+ const oldTimer = this.ownedDeliveryRunTimers.get(runId);
1558
+ if (oldTimer)
1559
+ clearTimeout(oldTimer);
1560
+ const timer = setTimeout(() => {
1561
+ this.ownedDeliveryRuns.delete(runId);
1562
+ this.ownedDeliveryRunTimers.delete(runId);
1563
+ }, delayMs);
1564
+ timer.unref?.();
1565
+ this.ownedDeliveryRunTimers.set(runId, timer);
1324
1566
  }
1325
1567
  addMutedProactiveKey(key) {
1326
1568
  const count = this.mutedProactiveSessionCounts.get(key) || 0;
@@ -1395,12 +1637,10 @@ export class OpenClawClient {
1395
1637
  next();
1396
1638
  }
1397
1639
  async chatSend(params) {
1398
- const sk = params.sessionKey;
1399
- const fullSessionKey = `agent:main:${sk}`;
1400
- const suppressedKeys = [sk, fullSessionKey];
1401
- this.suppressSessionKeys(suppressedKeys);
1402
- this.ownDeliverySessionKeys(suppressedKeys);
1640
+ const sk = this.shortKey(params.sessionKey);
1641
+ const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
1403
1642
  this.clearVerboseAssistantState(sk);
1643
+ let ownedRunId = "";
1404
1644
  try {
1405
1645
  // Drop stale buffered events for this session before starting a new run.
1406
1646
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -1413,7 +1653,7 @@ export class OpenClawClient {
1413
1653
  try {
1414
1654
  await params.onSendAttempt?.();
1415
1655
  result = await this.rpc("chat.send", {
1416
- sessionKey: sk,
1656
+ sessionKey: this.canonicalSessionKey(sk),
1417
1657
  message: params.message,
1418
1658
  attachments: params.attachments,
1419
1659
  deliver: params.deliver ?? false,
@@ -1423,6 +1663,8 @@ export class OpenClawClient {
1423
1663
  finally {
1424
1664
  releaseChatSendSlot();
1425
1665
  }
1666
+ ownedRunId = typeof result?.runId === "string" ? result.runId : "";
1667
+ this.ownDeliveryRun(ownedRunId);
1426
1668
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
1427
1669
  await params.onSubmitted?.(result.runId);
1428
1670
  if (params.onProgress) {
@@ -1439,8 +1681,7 @@ export class OpenClawClient {
1439
1681
  // are unaffected because they do not go through chatSend.
1440
1682
  this.progressCallbacks.delete(sk);
1441
1683
  this.progressCallbacks.delete(fullSessionKey);
1442
- this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
1443
- this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
1684
+ this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
1444
1685
  }
1445
1686
  }
1446
1687
  shouldInjectBridgeAttachmentHint(text) {
@@ -1630,9 +1871,9 @@ export class OpenClawClient {
1630
1871
  this.suppressedSessions.clear();
1631
1872
  this.mutedProactiveSessions.clear();
1632
1873
  this.mutedProactiveSessionCounts.clear();
1633
- for (const timer of this.ownedDeliverySessionTimers.values())
1874
+ for (const timer of this.ownedDeliveryRunTimers.values())
1634
1875
  clearTimeout(timer);
1635
- this.ownedDeliverySessionTimers.clear();
1876
+ this.ownedDeliveryRunTimers.clear();
1636
1877
  for (const timer of this.verboseAssistantTimers.values())
1637
1878
  clearTimeout(timer);
1638
1879
  this.verboseAssistantTimers.clear();
@@ -1640,7 +1881,10 @@ export class OpenClawClient {
1640
1881
  this.verboseAssistantSent.clear();
1641
1882
  this.verboseAssistantLastTouched.clear();
1642
1883
  this.lastToolNamesByItemId.clear();
1643
- this.ownedDeliverySessions.clear();
1884
+ this.recentToolEventKeys.clear();
1885
+ this.pendingSteerTexts.clear();
1886
+ this.steerConsumedCallbacks.clear();
1887
+ this.ownedDeliveryRuns.clear();
1644
1888
  if (this.ws) {
1645
1889
  this.ws.close();
1646
1890
  this.ws = null;
@@ -1658,9 +1902,7 @@ export class OpenClawClient {
1658
1902
  this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
1659
1903
  this.subscribedKeys.add(sessionKey);
1660
1904
  try {
1661
- // Try subscribing with short key first, then full key
1662
- await this.rpc("sessions.messages.subscribe", { key: sessionKey }).catch(() => { });
1663
- await this.rpc("sessions.messages.subscribe", { key: `agent:main:${sessionKey}` }).catch(() => { });
1905
+ await this.rpc("sessions.messages.subscribe", { key: this.canonicalSessionKey(sessionKey) });
1664
1906
  }
1665
1907
  catch (err) {
1666
1908
  console.warn(`[OpenClaw] Failed to subscribe ${sessionKey}:`, err.message);
@@ -1685,7 +1927,7 @@ export class OpenClawClient {
1685
1927
  this.setVerboseTranscriptDelivery(sessionKey, false);
1686
1928
  this.subscribedKeys.delete(sessionKey);
1687
1929
  try {
1688
- await this.rpc("sessions.messages.unsubscribe", { key: sessionKey });
1930
+ await this.rpc("sessions.messages.unsubscribe", { key: this.canonicalSessionKey(sessionKey) });
1689
1931
  }
1690
1932
  catch {
1691
1933
  // ignore