@clawling/clawchat-plugin-openclaw 2026.10.7-1 → 2026.10.8-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.
@@ -1,12 +1,13 @@
1
1
  import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
2
2
  import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
3
+ import { classifyFrameworkError, formatFrameworkErrorForOwner, isFrameworkErrorReply, } from "./framework-error-copy.js";
3
4
  import { createOpenclawClawlingApiClient } from "./api-client.js";
4
5
  import { effectiveOutputVisibility, } from "./config.js";
5
6
  import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
6
7
  import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
7
8
  import { isClawChatNoopResponseText } from "./profile-prompt.js";
8
9
  import { consumeTerminalClawChatSend } from "./terminal-send.js";
9
- import { createReplyStream } from "./reply-stream.js";
10
+ import { createReplyStream, streamableText } from "./reply-stream.js";
10
11
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
11
12
  /**
12
13
  * Re-send cadence for `typing.update {is_typing:true}` while a reply is in
@@ -265,7 +266,7 @@ function formatApprovalSummary(payload) {
265
266
  * as separate ClawChat messages.
266
267
  */
267
268
  export function createOpenclawClawlingReplyDispatcher(options) {
268
- const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, log, } = options;
269
+ const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, resolveOwnerLocale, log, } = options;
269
270
  const isGroupTarget = target.chatType === "group";
270
271
  const outputVisibility = effectiveOutputVisibility(account, target.chatId, target.chatType);
271
272
  const splitFullOutput = outputVisibility === "full";
@@ -344,7 +345,10 @@ export function createOpenclawClawlingReplyDispatcher(options) {
344
345
  // send, or an idle run with nothing delivered fails it. Host partials are snapshots of
345
346
  // the current assistant message that may be rewritten, so a snapshot that no
346
347
  // longer extends the preview fails the old stream and starts a new one.
347
- const streamEnabled = account.streamReplies === true;
348
+ // Direct chats only, same as Hermes. In a group the server's merged stream
349
+ // copy carries no @, and a receiver that dedupes by message_id keeps
350
+ // whichever copy lands first, so an @-only agent would never wake up.
351
+ const streamEnabled = account.streamReplies === true && !isGroupTarget;
348
352
  let replyStream = null;
349
353
  const failStream = (reason) => {
350
354
  replyStream?.fail(reason);
@@ -355,11 +359,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
355
359
  replyStream = null;
356
360
  return id;
357
361
  };
362
+ // Latest host snapshot of the current assistant message, including any tail
363
+ // the add throttle held back, so a boundary can close the preview whole.
364
+ let streamPartialText = "";
365
+ // Texts already delivered as their own message at a message boundary; a
366
+ // final that only repeats one of them is not sent again.
367
+ const finalizedPreviewTexts = new Set();
358
368
  const onStreamPartial = (text) => {
359
369
  if (runDone || terminalReplySuppressed)
360
370
  return;
361
- if (replyStream?.diverges(text))
362
- failStream("superseded");
371
+ // A snapshot that no longer extends the preview without a message
372
+ // boundary is a host rewrite: the stream stops growing and the final
373
+ // replaces it in place (same id). It is never retracted.
374
+ streamPartialText = text;
363
375
  replyStream ??= createReplyStream({
364
376
  client,
365
377
  chatId: target.chatId,
@@ -555,6 +567,36 @@ export function createOpenclawClawlingReplyDispatcher(options) {
555
567
  log,
556
568
  });
557
569
  };
570
+ // ----- Stream message boundary -----------------------------------------
571
+ //
572
+ // A new assistant message (after a tool call or a thinking block) means the
573
+ // previewed one is complete. Close it as its own message — `message.done` +
574
+ // `message.reply` under the same id — instead of letting the next snapshot
575
+ // supersede it, so text the recipient already saw stays. A preview still
576
+ // held back (never shown) is just dropped.
577
+ const finalizeStreamAtBoundary = async (reason) => {
578
+ if (!replyStream)
579
+ return;
580
+ if (!replyStream.isOpen()) {
581
+ replyStream = null;
582
+ streamPartialText = "";
583
+ return;
584
+ }
585
+ const decision = streamableText(streamPartialText);
586
+ const text = decision.kind === "ok"
587
+ ? decision.text
588
+ : decision.kind === "poisoned"
589
+ ? streamPartialText
590
+ : replyStream.shownText();
591
+ streamPartialText = "";
592
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw stream boundary reason=${reason} text_len=${text.length} to=${target.chatId}`);
593
+ const result = await sendStatic(text, [], [], { recordMessage: true });
594
+ if (result)
595
+ finalizedPreviewTexts.add(text.trim());
596
+ // Whatever sendStatic did (sent, suppressed as no-reply, skipped after a
597
+ // terminal tool send), the stream is closed now.
598
+ replyStream = null;
599
+ };
558
600
  // ----- Static send ------------------------------------------------------
559
601
  const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
560
602
  const messageMode = options.messageMode ?? "normal";
@@ -753,7 +795,16 @@ export function createOpenclawClawlingReplyDispatcher(options) {
753
795
  });
754
796
  return;
755
797
  }
756
- const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(text);
798
+ // OpenClaw's own failure copy (isError, or a leading ⚠️) is English
799
+ // and often operator-facing; lead it with an owner-language sentence
800
+ // and keep the original at the end.
801
+ const deliveredText = isFrameworkErrorReply(payload, text)
802
+ ? formatFrameworkErrorForOwner(text, resolveOwnerLocale?.() ?? null)
803
+ : text;
804
+ if (deliveredText !== text) {
805
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final framework error localized category=${classifyFrameworkError(text)}`);
806
+ }
807
+ const finalText = richFragment && account.richInteractions ? mergeFinalText("") : mergeFinalText(deliveredText);
757
808
  const finalUrls = mergeFinalUrls(urls);
758
809
  if (isClawChatNoopResponseText(finalText) &&
759
810
  !richFragment &&
@@ -762,6 +813,13 @@ export function createOpenclawClawlingReplyDispatcher(options) {
762
813
  failStream("no-reply");
763
814
  return;
764
815
  }
816
+ if (!replyStream?.isOpen() &&
817
+ finalizedPreviewTexts.has(finalText.trim()) &&
818
+ !richFragment &&
819
+ finalUrls.length === 0) {
820
+ log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw final skipped: already delivered at a message boundary`);
821
+ return;
822
+ }
765
823
  const mediaFragments = await uploadMediaUrls(finalUrls);
766
824
  const result = await sendStatic(finalText, mediaFragments, richFragment && account.richInteractions ? [richFragment] : [], { recordMessage: true });
767
825
  if (result?.messageId)
@@ -786,16 +844,31 @@ export function createOpenclawClawlingReplyDispatcher(options) {
786
844
  return;
787
845
  const fallbackText = bufferedOutputText.trim();
788
846
  const fallbackUrls = bufferedOutputUrls.slice();
789
- if (!fallbackText && fallbackUrls.length === 0)
847
+ if (!fallbackText && fallbackUrls.length === 0) {
848
+ // No final came, but a preview was shown: keep it as the reply
849
+ // rather than retracting text the recipient already read.
850
+ if (replyStream?.isOpen())
851
+ await finalizeStreamAtBoundary("idle");
790
852
  return;
853
+ }
791
854
  const mediaFragments = await uploadMediaUrls(fallbackUrls);
792
855
  const result = await sendStatic(fallbackText, mediaFragments, [], { recordMessage: true });
793
856
  if (result?.messageId)
794
857
  recordThinkingIfLinked(result.messageId);
795
858
  }
796
859
  finally {
797
- // A preview nothing materialized must not linger as an open stream.
798
- failStream("undelivered");
860
+ // Safety net: nothing above claimed or closed an open stream (e.g. the
861
+ // finalize send threw). It must not linger open, and text already
862
+ // shown must not vanish: close it with what was shown (the server
863
+ // materializes that from message.done). Only a preview whose snapshot
864
+ // turned out to be a no-reply is retracted.
865
+ if (replyStream?.isOpen()) {
866
+ if (streamableText(streamPartialText).kind === "poisoned")
867
+ failStream("no-reply");
868
+ else
869
+ claimStreamMessageId(replyStream.shownText());
870
+ }
871
+ replyStream = null;
799
872
  }
800
873
  },
801
874
  onCleanup: () => {
@@ -830,13 +903,22 @@ export function createOpenclawClawlingReplyDispatcher(options) {
830
903
  reasoningText = reasoningText ? `${reasoningText}\n${trimmed}` : trimmed;
831
904
  }
832
905
  : undefined,
833
- onToolStart: splitFullOutput
906
+ onToolStart: splitFullOutput || streamEnabled
834
907
  ? async (payload) => {
908
+ if (streamEnabled)
909
+ await finalizeStreamAtBoundary("tool-start");
910
+ if (!splitFullOutput)
911
+ return;
835
912
  if (consumeTerminalSend("tool-start"))
836
913
  return;
837
914
  await emitProcessSegment(formatToolStartSummary(payload));
838
915
  }
839
916
  : undefined,
917
+ onAssistantMessageStart: streamEnabled
918
+ ? async () => {
919
+ await finalizeStreamAtBoundary("assistant-message-start");
920
+ }
921
+ : undefined,
840
922
  onToolResult: splitFullOutput
841
923
  ? async (payload) => {
842
924
  if (consumeTerminalSend("tool-result"))
@@ -11,12 +11,13 @@
11
11
  * and the server materializes its merged reply from the stream. `finish`
12
12
  * therefore takes the final text, sends whatever tail of it the throttle held
13
13
  * back as one last add, and closes with the whole text. A final that does not
14
- * extend what was shown cannot be appended (§8.2) — the stream is failed and
15
- * the final goes out under its own id.
14
+ * extend what was shown cannot be appended (§8.2): `message.done` then closes
15
+ * with the shown text and the final reply, under the same id, replaces it in
16
+ * place, so text the recipient already saw never vanishes.
16
17
  *
17
- * or, when the reply turns out not to be sendable (no-reply token, run
18
- * aborted, nothing delivered): message.created(M) → … → message.failed(M),
19
- * which aborts the stream without materializing anything.
18
+ * or, when the preview must not stay (a no-reply, or the reply went out
19
+ * through a ClawChat tool instead): message.created(M) → … → message.failed(M),
20
+ * which retracts the preview — the recipient keeps nothing of it.
20
21
  *
21
22
  * Streaming frames get no ack (§8.5) and no server-side mention repair, so the
22
23
  * stream only ever carries plain text; mentions, media and rich fragments ride
@@ -184,23 +185,24 @@ export function createReplyStream(options) {
184
185
  return null;
185
186
  }
186
187
  // Trailing whitespace a partial carried may be trimmed off the final.
187
- if (!finalText.startsWith(sent.trimEnd())) {
188
- fail("superseded");
189
- return null;
190
- }
191
- if (finalText.length > sent.length && finalText.startsWith(sent)) {
188
+ const extendsShown = finalText.startsWith(sent.trimEnd());
189
+ if (extendsShown && finalText.length > sent.length && finalText.startsWith(sent)) {
192
190
  // Past the throttle: this is the last frame before done.
193
191
  sendAdd(finalText, now());
194
192
  }
193
+ // A final that rewrites the preview cannot be appended (§8.2), but the
194
+ // shown text must not vanish: close the stream with what was shown and
195
+ // let the final reply under this same id replace it in place.
196
+ const doneText = extendsShown ? finalText : sent;
195
197
  closed = true;
196
198
  const at = now();
197
199
  emit(EVENT.MESSAGE_DONE, {
198
200
  message_id: messageId,
199
- fragments: [{ kind: "text", text: finalText }],
201
+ fragments: [{ kind: "text", text: doneText }],
200
202
  streaming: streamingState("done", at),
201
203
  completed_at: at,
202
204
  });
203
- options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}`);
205
+ options.log?.info?.(`${prefix} stream done msg=${messageId} text_len=${finalText.length}${extendsShown ? "" : " replaced_in_place=true"}`);
204
206
  return messageId;
205
207
  },
206
208
  fail,
@@ -859,6 +859,10 @@ export async function startOpenclawClawlingGateway(params) {
859
859
  // `sendOwnerSkillMessage` below) — both live in this same function closure, so
860
860
  // the forward reference here just resolves to the fresh value at call time.
861
861
  let livewareSampleNotify = async () => false;
862
+ // The supervisor this gateway call shares (new or adopted). The abort cleanup
863
+ // below awaits its stop() so the host's channel stop (which waits on this
864
+ // task) does not return while liveware children are still being killed.
865
+ let livewareSampleForShutdown = null;
862
866
  if (store) {
863
867
  const stateDirForSample = runtime.state?.resolveStateDir?.();
864
868
  const livewareSampleDeps = {
@@ -901,6 +905,7 @@ export async function startOpenclawClawlingGateway(params) {
901
905
  livewareSampleSupervisors.set(abortSignal, supervisorsForSignal);
902
906
  const supervisorKey = `openclaw:${accountId}`;
903
907
  const runningSupervisor = supervisorsForSignal.get(supervisorKey);
908
+ livewareSampleForShutdown = runningSupervisor ?? null;
904
909
  if (runningSupervisor) {
905
910
  // A gateway re-entry (refresh/backoff reconnect), not a fresh channel start:
906
911
  // hand the live supervisor this closure's token + REST client + notify path
@@ -910,9 +915,12 @@ export async function startOpenclawClawlingGateway(params) {
910
915
  }
911
916
  else {
912
917
  const livewareSampleSupervisor = new LivewareSampleSupervisor(livewareSampleDeps);
918
+ livewareSampleForShutdown = livewareSampleSupervisor;
913
919
  supervisorsForSignal.set(supervisorKey, livewareSampleSupervisor);
914
920
  abortSignal.addEventListener("abort", () => {
915
- livewareSampleSupervisor.stop();
921
+ // Starts the SIGTERM → SIGKILL teardown at once; the gateway's own
922
+ // abort cleanup awaits the same (idempotent) stop() promise.
923
+ void livewareSampleSupervisor.stop();
916
924
  if (supervisorsForSignal.get(supervisorKey) === livewareSampleSupervisor) {
917
925
  supervisorsForSignal.delete(supervisorKey);
918
926
  }
@@ -1188,6 +1196,25 @@ export async function startOpenclawClawlingGateway(params) {
1188
1196
  const persistOwnerProfile = upsertOwnerProfile
1189
1197
  ? makeOwnerProfilePersister({ upsertOwnerProfile }, { accountId })
1190
1198
  : undefined;
1199
+ // The owner_profile row is where owner-language copy reads its locale
1200
+ // (framework errors, greetings, liveware intros). It was only refreshed on
1201
+ // owner metadata pulls, so an owner who switched app language kept getting
1202
+ // the old one. friend.profile_updated also reaches the agent when the
1203
+ // owner edits their profile; refetch just the owner row then.
1204
+ const refreshOwnerProfileOnUpdate = (entityId) => {
1205
+ const subject = typeof entityId === "string" ? entityId.trim() : "";
1206
+ if (!persistOwnerProfile || !subject || subject !== account.ownerUserId)
1207
+ return;
1208
+ void (async () => {
1209
+ try {
1210
+ const owner = await getConversationApiClient().getAgentOwner();
1211
+ persistOwnerProfile(owner);
1212
+ }
1213
+ catch (err) {
1214
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw owner profile refresh failed source=friend.profile_updated: ${err instanceof Error ? err.message : String(err)}`);
1215
+ }
1216
+ })();
1217
+ };
1191
1218
  const refreshAgentBehavior = async (options) => {
1192
1219
  try {
1193
1220
  await pullOwnerMetadata({
@@ -2305,6 +2332,9 @@ export async function startOpenclawClawlingGateway(params) {
2305
2332
  if (type === "friend.added") {
2306
2333
  scheduleFriendGreeting(payload);
2307
2334
  }
2335
+ if (type === "friend.profile_updated") {
2336
+ refreshOwnerProfileOnUpdate(payload?.entity_id);
2337
+ }
2308
2338
  }
2309
2339
  }
2310
2340
  });
@@ -2409,6 +2439,8 @@ export async function startOpenclawClawlingGateway(params) {
2409
2439
  ? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
2410
2440
  : metadata?.nickname ?? userId;
2411
2441
  const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
2442
+ if (name !== userId)
2443
+ knownNicknames.set(userId, name);
2412
2444
  participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
2413
2445
  }
2414
2446
  return participants;
@@ -2444,6 +2476,7 @@ export async function startOpenclawClawlingGateway(params) {
2444
2476
  basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
2445
2477
  ...promptMetadata,
2446
2478
  ownerIdFallback: account.ownerUserId,
2479
+ currentAgentIdFallback: account.userId,
2447
2480
  groupParticipants,
2448
2481
  turn: {
2449
2482
  chatType: promptChatType,
@@ -2463,9 +2496,105 @@ export async function startOpenclawClawlingGateway(params) {
2463
2496
  },
2464
2497
  });
2465
2498
  };
2499
+ // Group frames often carry the sender's id as nick_name. Prefer the nickname
2500
+ // already cached in ClawChat memory (owner.md for the owner and this agent,
2501
+ // users/<id>.md for everyone else, the group's group_owner_nickname for its
2502
+ // owner); keep the id when nothing is cached.
2503
+ const cachedNicknameFor = async (memoryRoot, userId) => {
2504
+ try {
2505
+ if (userId === account.ownerUserId || userId === account.userId) {
2506
+ const ownerFile = await readClawChatMemoryFile(memoryRoot, { targetType: "owner", targetId: "owner" });
2507
+ const nickname = userId === account.userId
2508
+ ? ownerFile.metadata.agent_nickname
2509
+ : ownerFile.metadata.agent_owner_nickname;
2510
+ if (nickname)
2511
+ return nickname;
2512
+ }
2513
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
2514
+ return userFile.exists && userFile.metadata.nickname ? userFile.metadata.nickname : null;
2515
+ }
2516
+ catch {
2517
+ return null;
2518
+ }
2519
+ };
2520
+ // Nicknames seen while building group prompts, so ingest can label a
2521
+ // coalesced batch's transcript ("Alice:" rather than "usr_…:") without
2522
+ // touching disk.
2523
+ const knownNicknames = new Map();
2524
+ const withCachedGroupSenderNames = async (turn, memoryRoot) => {
2525
+ if (turn.peer.kind !== "group" || !memoryRoot)
2526
+ return turn;
2527
+ const isBareId = (name, id) => !name || name === id;
2528
+ const cache = new Map();
2529
+ let groupOwner = null;
2530
+ const lookup = async (userId) => {
2531
+ if (!cache.has(userId)) {
2532
+ let nickname = await cachedNicknameFor(memoryRoot, userId);
2533
+ if (!nickname) {
2534
+ if (!groupOwner) {
2535
+ try {
2536
+ const groupFile = await readClawChatMemoryFile(memoryRoot, { targetType: "group", targetId: turn.peer.id });
2537
+ groupOwner = { id: groupFile.metadata.group_owner_id, nickname: groupFile.metadata.group_owner_nickname };
2538
+ }
2539
+ catch {
2540
+ groupOwner = {};
2541
+ }
2542
+ }
2543
+ if (groupOwner.id === userId && groupOwner.nickname)
2544
+ nickname = groupOwner.nickname;
2545
+ }
2546
+ cache.set(userId, nickname);
2547
+ if (nickname)
2548
+ knownNicknames.set(userId, nickname);
2549
+ }
2550
+ return cache.get(userId) ?? null;
2551
+ };
2552
+ const senderNickName = isBareId(turn.senderNickName, turn.senderId)
2553
+ ? (await lookup(turn.senderId)) ?? turn.senderNickName
2554
+ : turn.senderNickName;
2555
+ // The wire carries no sender type, so a batch message from another agent
2556
+ // (Hermes, a second OpenClaw…) would read as a person. users/<id>.md holds
2557
+ // the backend's user type; this agent is always an agent.
2558
+ const typeCache = new Map();
2559
+ const lookupProfileType = async (userId) => {
2560
+ if (userId === account.userId)
2561
+ return "agent";
2562
+ if (!typeCache.has(userId)) {
2563
+ let profileType = null;
2564
+ try {
2565
+ const userFile = await readClawChatMemoryFile(memoryRoot, { targetType: "user", targetId: userId });
2566
+ profileType = userFile.exists && userFile.metadata.profile_type ? userFile.metadata.profile_type : null;
2567
+ }
2568
+ catch {
2569
+ profileType = null;
2570
+ }
2571
+ typeCache.set(userId, profileType);
2572
+ }
2573
+ return typeCache.get(userId) ?? null;
2574
+ };
2575
+ const groupMessages = turn.groupMessages
2576
+ ? await Promise.all(turn.groupMessages.map(async (message) => {
2577
+ const named = isBareId(message.senderName, message.senderId)
2578
+ ? { ...message, senderName: (await lookup(message.senderId)) ?? message.senderName }
2579
+ : message;
2580
+ const profileType = await lookupProfileType(message.senderId);
2581
+ if (!profileType || profileType === named.senderProfileType)
2582
+ return named;
2583
+ const senderRelation = profileType === "agent" && named.senderRelation === "peer_user"
2584
+ ? "peer_agent"
2585
+ : named.senderRelation;
2586
+ return { ...named, senderProfileType: profileType, senderRelation };
2587
+ }))
2588
+ : turn.groupMessages;
2589
+ return { ...turn, senderNickName, ...(groupMessages ? { groupMessages } : {}) };
2590
+ };
2466
2591
  const resolveSenderNickNameForTurn = (turn) => {
2467
2592
  if (turn.senderNickName && turn.senderNickName !== turn.senderId)
2468
2593
  return turn.senderNickName;
2594
+ // Synchronous on purpose: an await here would reorder same-group frames.
2595
+ const known = turn.peer.kind === "group" ? knownNicknames.get(turn.senderId) : undefined;
2596
+ if (known)
2597
+ return known;
2469
2598
  return turn.senderNickName || turn.senderId;
2470
2599
  };
2471
2600
  const resolveSenderBatchIdentityForTurn = (turn) => {
@@ -2480,7 +2609,9 @@ export async function startOpenclawClawlingGateway(params) {
2480
2609
  senderProfileType: turn.senderProfileType ??
2481
2610
  (senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
2482
2611
  senderIsOwner: senderRelation === "owner",
2483
- senderIsGroupOwner: turn.senderIsGroupOwner ?? false,
2612
+ // Left undefined when the frame does not say, so the prompt falls back
2613
+ // to the group metadata's group_owner_id instead of a forced false.
2614
+ ...(turn.senderIsGroupOwner !== undefined ? { senderIsGroupOwner: turn.senderIsGroupOwner } : {}),
2484
2615
  };
2485
2616
  };
2486
2617
  const claimInboundTurn = (turn) => {
@@ -2550,6 +2681,7 @@ export async function startOpenclawClawlingGateway(params) {
2550
2681
  catch (err) {
2551
2682
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`);
2552
2683
  }
2684
+ turn = await withCachedGroupSenderNames(turn, memoryRoot);
2553
2685
  const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
2554
2686
  const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
2555
2687
  const senderIsOwner = turn.senderIsOwner === true || turn.senderId === account.ownerUserId;
@@ -2680,6 +2812,7 @@ export async function startOpenclawClawlingGateway(params) {
2680
2812
  runtime,
2681
2813
  account,
2682
2814
  client,
2815
+ resolveOwnerLocale: () => store?.getOwnerProfile?.({ platform: "openclaw", accountId })?.locale ?? null,
2683
2816
  target: { chatId: turn.peer.id, chatType: turn.peer.kind },
2684
2817
  ...(replyCtx ? { replyCtx } : {}),
2685
2818
  inboundMessageId: turn.messageId,
@@ -3270,6 +3403,16 @@ export async function startOpenclawClawlingGateway(params) {
3270
3403
  closeReason: "client close",
3271
3404
  });
3272
3405
  client.close();
3406
+ if (livewareSampleForShutdown) {
3407
+ // SIGTERM, then SIGKILL after KILL_GRACE_MS (3 s): inside the host's
3408
+ // 5 s wait for this task, so a stuck child no longer outlives the stop.
3409
+ try {
3410
+ await livewareSampleForShutdown.stop();
3411
+ }
3412
+ catch (err) {
3413
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw liveware-sample stop failed: ${err instanceof Error ? err.message : String(err)}`);
3414
+ }
3415
+ }
3273
3416
  setStatus({
3274
3417
  ...getStatus(),
3275
3418
  connected: false,
@@ -66,7 +66,7 @@ export const OFFICIAL_SKILLS_BASE = "https://raw.githubusercontent.com/clawling/
66
66
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
67
67
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
68
68
  */
69
- export const DEFAULT_SKILLS_REF = "skills-v1.16.0";
69
+ export const DEFAULT_SKILLS_REF = "skills-v1.17.0";
70
70
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
71
71
  export const MAX_SKILL_BYTES = 256 * 1024;
72
72
  /** This adapter's host target inside `skills/manifest.json`. */
@@ -205,7 +205,7 @@ export const ClawchatCreateMomentSchema = Type.Object({
205
205
  description: "Moment text. At least one of text or images is required.",
206
206
  })),
207
207
  images: Type.Optional(Type.Array(Type.String({
208
- description: "Image URL for the moment. Upload local files first; do not pass local paths.",
208
+ description: "An http(s) image URL, or an absolute local image path (the tool uploads it and uses the hosted URL).",
209
209
  }))),
210
210
  });
211
211
  export const ClawchatDeleteMomentSchema = Type.Object({
package/dist/src/tools.js CHANGED
@@ -28,6 +28,15 @@ function extractRequestId(err) {
28
28
  }
29
29
  return undefined;
30
30
  }
31
+ /**
32
+ * What the agent must do with the later `permission_result` receipt
33
+ * (agent-protocol §2.8): on approval the SERVER executes the gated call, so a
34
+ * second call would duplicate it or raise a fresh card. Only `approved_retry`
35
+ * (reads, which are never replayed) asks for exactly one more call.
36
+ */
37
+ const PERMISSION_RECEIPT_RULE = "When the result arrives: outcome \"approved\" (or \"auto_allowed\") means the server has already done it for you — do not call this tool again; " +
38
+ "only outcome \"approved_retry\" means call this same tool once more with the same arguments. " +
39
+ "Anything the server produced (a new id, a code, read data) comes in that message's Result line.";
31
40
  function permissionGateResult(err) {
32
41
  const code = err.meta?.code;
33
42
  const requestId = extractRequestId(err);
@@ -38,7 +47,8 @@ function permissionGateResult(err) {
38
47
  "This operation requires the owner's approval and has been submitted for review" +
39
48
  (requestId ? ` (request_id=${requestId})` : "") +
40
49
  ". It has NOT failed. Do not retry — the result will arrive later as a normal " +
41
- "chat message; wait for it instead of calling this tool again.";
50
+ "chat message; wait for it instead of calling this tool again. " +
51
+ PERMISSION_RECEIPT_RULE;
42
52
  status = "pending";
43
53
  }
44
54
  else {
@@ -120,6 +130,46 @@ function inferMimeFromPath(filePath) {
120
130
  const ext = path.extname(filePath).toLowerCase();
121
131
  return MIME_BY_EXT[ext] ?? "application/octet-stream";
122
132
  }
133
+ /**
134
+ * Classify `clawchat_create_moment` image entries: http(s) URLs pass through;
135
+ * an existing absolute local regular file (≤ 20MB) is marked for upload; any
136
+ * other value is a validation error naming the offending index.
137
+ */
138
+ function planMomentImages(images) {
139
+ const entries = [];
140
+ for (const [i, raw] of images.entries()) {
141
+ const where = `clawchat-plugin-openclaw: images[${i}]`;
142
+ const value = typeof raw === "string" ? raw.trim() : "";
143
+ if (!value) {
144
+ return { ok: false, message: `${where} must be an http(s) image URL or an absolute local image path` };
145
+ }
146
+ if (/^https?:\/\/\S+$/i.test(value)) {
147
+ entries.push({ kind: "url", url: value });
148
+ continue;
149
+ }
150
+ if (!path.isAbsolute(value)) {
151
+ return {
152
+ ok: false,
153
+ message: `${where} (${JSON.stringify(value)}) is neither an http(s) URL nor an absolute local file path; no moment was created`,
154
+ };
155
+ }
156
+ let stat;
157
+ try {
158
+ stat = fs.statSync(value);
159
+ }
160
+ catch {
161
+ return { ok: false, message: `${where}: local file ${value} does not exist; no moment was created` };
162
+ }
163
+ if (!stat.isFile()) {
164
+ return { ok: false, message: `${where}: ${value} is not a regular file; no moment was created` };
165
+ }
166
+ if (stat.size > MAX_UPLOAD_BYTES) {
167
+ return { ok: false, message: `${where}: ${value} is too large (${stat.size} bytes; max 20MB)` };
168
+ }
169
+ entries.push({ kind: "local", path: value });
170
+ }
171
+ return { ok: true, entries };
172
+ }
123
173
  function validateMentionMessageParams(params) {
124
174
  if (!params || typeof params !== "object") {
125
175
  return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
@@ -285,6 +335,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
285
335
  ok: true,
286
336
  client: createOpenclawClawlingApiClient({
287
337
  baseUrl: acct.baseUrl,
338
+ mediaBaseUrl: acct.mediaBaseUrl,
288
339
  token: acct.token,
289
340
  userId: acct.userId,
290
341
  }),
@@ -329,8 +380,16 @@ export function registerOpenclawClawlingTools(api, options = {}) {
329
380
  ? rawData
330
381
  : {};
331
382
  const outcome = mapGateOutcome(code, gateData);
332
- if (outcome !== null)
333
- return jsonResponse(outcome);
383
+ if (outcome !== null) {
384
+ // `next_step` is added here, not in mapGateOutcome: that mapping
385
+ // is pinned byte-for-byte against the Hermes parity fixtures.
386
+ return jsonResponse(outcome.status === "pending_owner_approval"
387
+ ? {
388
+ ...outcome,
389
+ next_step: "Waiting for the owner's approval; do not call this tool again now. " + PERMISSION_RECEIPT_RULE,
390
+ }
391
+ : outcome);
392
+ }
334
393
  }
335
394
  return apiError(err);
336
395
  }
@@ -1100,7 +1159,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1100
1159
  label: "Create ClawChat Moment",
1101
1160
  description: toolDescription("Create a new ClawChat moment/dynamic for the configured ClawChat account. " +
1102
1161
  "TRIGGER - invoke when the user asks to publish, post, or send a ClawChat moment/dynamic, such as \"post a ClawChat moment saying ...\", \"publish a dynamic ...\", or \"send a friend moment ...\". " +
1103
- "At least one of text or images must be present. For local image files, upload first with the appropriate media upload tool and pass the returned URLs in images; do not pass local file paths as images."),
1162
+ "At least one of text or images must be present. Each images entry is either an http(s) image URL (used as-is) or an absolute local image path (max 20MB) — for a local path this tool uploads it to ClawChat media itself and uses the hosted URL, so no separate upload step is needed. Any other value (relative path, file:// or data: URI, missing file) is rejected and no moment is created."),
1104
1163
  parameters: ClawchatCreateMomentSchema,
1105
1164
  async execute(_callId, params) {
1106
1165
  return await recordClawchatToolCall(accountId, "clawchat_create_moment", params, async () => {
@@ -1110,10 +1169,33 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1110
1169
  if (!text && (!images || images.length === 0)) {
1111
1170
  return validationError("clawchat-plugin-openclaw: at least one of text or images is required");
1112
1171
  }
1113
- return await withGatedClient(accountId, (c) => c.createMoment({
1114
- ...(text !== undefined ? { text } : {}),
1115
- ...(images !== undefined ? { images } : {}),
1116
- }));
1172
+ // Validate every entry before touching the network, so a bad value
1173
+ // never leaves a half-uploaded batch or a moment with a broken image.
1174
+ const plan = images !== undefined ? planMomentImages(images) : { ok: true, entries: [] };
1175
+ if (!plan.ok)
1176
+ return validationError(plan.message);
1177
+ return await withGatedClient(accountId, async (c) => {
1178
+ const resolved = [];
1179
+ for (const entry of plan.entries) {
1180
+ if (entry.kind === "url") {
1181
+ resolved.push(entry.url);
1182
+ continue;
1183
+ }
1184
+ const uploaded = await c.uploadMedia({
1185
+ buffer: fs.readFileSync(entry.path),
1186
+ filename: path.basename(entry.path),
1187
+ mime: inferMimeFromPath(entry.path),
1188
+ });
1189
+ if (uploaded.kind !== "image") {
1190
+ throw new Error(`clawchat-plugin-openclaw: ${entry.path} is not an image (media service classified it as ${uploaded.kind}); no moment was created`);
1191
+ }
1192
+ resolved.push(uploaded.url);
1193
+ }
1194
+ return await c.createMoment({
1195
+ ...(text !== undefined ? { text } : {}),
1196
+ ...(images !== undefined ? { images: resolved } : {}),
1197
+ });
1198
+ });
1117
1199
  });
1118
1200
  },
1119
1201
  };