@alfe.ai/openclaw-chat 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.cjs";
1
+ import { a as plugin, c as AlfeChannelConfig, l as AlfePluginConfig, s as createAlfeChannelPlugin, u as AlfeResolvedAccount } from "./plugin.cjs";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.js";
1
+ import { a as plugin, c as AlfeChannelConfig, l as AlfePluginConfig, s as createAlfeChannelPlugin, u as AlfeResolvedAccount } from "./plugin.js";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { i as createAlfeChannelPlugin, n as plugin } from "./plugin2.js";
1
+ import { i as plugin, o as createAlfeChannelPlugin } from "./plugin2.js";
2
2
  export { createAlfeChannelPlugin, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -3,6 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_plugin = require("./plugin2.cjs");
6
+ exports.admitAgentEvent = require_plugin.admitAgentEvent;
6
7
  exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
8
+ exports.buildToolActivity = require_plugin.buildToolActivity;
7
9
  exports.default = require_plugin.plugin;
8
10
  exports.stripLeakedToolSummary = require_plugin.stripLeakedToolSummary;
package/dist/plugin.d.cts CHANGED
@@ -268,12 +268,80 @@ interface AgentEventPayload {
268
268
  data: Record<string, unknown>;
269
269
  sessionKey?: string;
270
270
  }
271
+ interface RunEventGate {
272
+ runId: string | null;
273
+ sessionKey: string | null;
274
+ }
275
+ /**
276
+ * Decide whether an agent-event belongs to THIS turn's run and should be
277
+ * forwarded as a delta / activity frame.
278
+ *
279
+ * Why gate on `runId`, not `sessionKey`:
280
+ * On OpenClaw 2026.x the daemon registers this ('alfe') channel's main run
281
+ * with `isControlUiVisible: false` — only the built-in "webchat" surface is
282
+ * treated as control-UI-visible (`isInternalMessageChannel`). The shared
283
+ * agent-event bus then NULLS `sessionKey` on every event of a
284
+ * non-control-UI run (`agent-events`: `sessionKey = isControlUiVisible ? … :
285
+ * undefined`). So main-run assistant/thinking/tool events arrive with
286
+ * `sessionKey: undefined`, and the old `!evt.sessionKey` + key-equality gate
287
+ * silently dropped ALL activity — replies still landed because they come via
288
+ * the `deliver()` callback, not the event bus (the reported "replies arrive,
289
+ * zero activity" symptom). Verified against openclaw@2026.6.10 and
290
+ * back-compatible with 2026.4.2 (identical emit semantics). `runId` is always
291
+ * present on every emitted event.
292
+ *
293
+ * Isolation is preserved: turns to a daemon are serialised
294
+ * (`enqueueAgentTurn`) and the listener is torn down before the next turn
295
+ * dispatches, so the first relevant event locks onto THIS turn's main run.
296
+ * Concurrent heartbeat/cron runs and subagent / side-question runs carry a
297
+ * different `runId` and are excluded — exactly the leakage the gate exists to
298
+ * prevent. When a run IS control-UI-visible and both keys are present, a
299
+ * mismatched `sessionKey` is still rejected as a secondary defence.
300
+ *
301
+ * Exported for unit tests — this gate is the fix's load-bearing logic and is
302
+ * pinned against real 2026.6.10 event shapes.
303
+ */
304
+ declare function admitAgentEvent(gate: RunEventGate, evt: Pick<AgentEventPayload, 'runId' | 'stream' | 'sessionKey'>): boolean;
271
305
  interface PluginServiceContext {
272
306
  config: Record<string, unknown>;
273
307
  workspaceDir?: string;
274
308
  stateDir: string;
275
309
  logger: PluginLogger;
276
310
  }
311
+ type ChatActivity = {
312
+ kind: 'thinking';
313
+ text: string;
314
+ delta?: boolean;
315
+ ts?: number;
316
+ seq?: number;
317
+ } | {
318
+ kind: 'tool';
319
+ toolCallId: string;
320
+ name: string;
321
+ phase?: string;
322
+ status: 'running' | 'done' | 'failed';
323
+ summary?: string;
324
+ argsText?: string;
325
+ progressText?: string;
326
+ resultText?: string;
327
+ isError?: boolean;
328
+ durationMs?: number;
329
+ truncated?: {
330
+ args?: boolean;
331
+ progress?: boolean;
332
+ result?: boolean;
333
+ };
334
+ ts?: number;
335
+ seq?: number;
336
+ };
337
+ /**
338
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
339
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
340
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
341
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
342
+ * event already provides one (title/summary/label/description), trimmed.
343
+ */
344
+ declare function buildToolActivity(data: Record<string, unknown>): ChatActivity | null;
277
345
  declare function stripLeakedToolSummary(text: string): string;
278
346
  /**
279
347
  * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
@@ -328,4 +396,4 @@ declare const plugin: {
328
396
  deactivate(api: PluginApi): Promise<void>;
329
397
  };
330
398
  //#endregion
331
- export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
399
+ export { plugin as a, AlfeChannelConfig as c, buildToolActivity as i, AlfePluginConfig as l, admitAgentEvent as n, stripLeakedToolSummary as o, buildA2ACompletePayload as r, createAlfeChannelPlugin as s, RunEventGate as t, AlfeResolvedAccount as u };
package/dist/plugin.d.ts CHANGED
@@ -268,12 +268,80 @@ interface AgentEventPayload {
268
268
  data: Record<string, unknown>;
269
269
  sessionKey?: string;
270
270
  }
271
+ interface RunEventGate {
272
+ runId: string | null;
273
+ sessionKey: string | null;
274
+ }
275
+ /**
276
+ * Decide whether an agent-event belongs to THIS turn's run and should be
277
+ * forwarded as a delta / activity frame.
278
+ *
279
+ * Why gate on `runId`, not `sessionKey`:
280
+ * On OpenClaw 2026.x the daemon registers this ('alfe') channel's main run
281
+ * with `isControlUiVisible: false` — only the built-in "webchat" surface is
282
+ * treated as control-UI-visible (`isInternalMessageChannel`). The shared
283
+ * agent-event bus then NULLS `sessionKey` on every event of a
284
+ * non-control-UI run (`agent-events`: `sessionKey = isControlUiVisible ? … :
285
+ * undefined`). So main-run assistant/thinking/tool events arrive with
286
+ * `sessionKey: undefined`, and the old `!evt.sessionKey` + key-equality gate
287
+ * silently dropped ALL activity — replies still landed because they come via
288
+ * the `deliver()` callback, not the event bus (the reported "replies arrive,
289
+ * zero activity" symptom). Verified against openclaw@2026.6.10 and
290
+ * back-compatible with 2026.4.2 (identical emit semantics). `runId` is always
291
+ * present on every emitted event.
292
+ *
293
+ * Isolation is preserved: turns to a daemon are serialised
294
+ * (`enqueueAgentTurn`) and the listener is torn down before the next turn
295
+ * dispatches, so the first relevant event locks onto THIS turn's main run.
296
+ * Concurrent heartbeat/cron runs and subagent / side-question runs carry a
297
+ * different `runId` and are excluded — exactly the leakage the gate exists to
298
+ * prevent. When a run IS control-UI-visible and both keys are present, a
299
+ * mismatched `sessionKey` is still rejected as a secondary defence.
300
+ *
301
+ * Exported for unit tests — this gate is the fix's load-bearing logic and is
302
+ * pinned against real 2026.6.10 event shapes.
303
+ */
304
+ declare function admitAgentEvent(gate: RunEventGate, evt: Pick<AgentEventPayload, 'runId' | 'stream' | 'sessionKey'>): boolean;
271
305
  interface PluginServiceContext {
272
306
  config: Record<string, unknown>;
273
307
  workspaceDir?: string;
274
308
  stateDir: string;
275
309
  logger: PluginLogger;
276
310
  }
311
+ type ChatActivity = {
312
+ kind: 'thinking';
313
+ text: string;
314
+ delta?: boolean;
315
+ ts?: number;
316
+ seq?: number;
317
+ } | {
318
+ kind: 'tool';
319
+ toolCallId: string;
320
+ name: string;
321
+ phase?: string;
322
+ status: 'running' | 'done' | 'failed';
323
+ summary?: string;
324
+ argsText?: string;
325
+ progressText?: string;
326
+ resultText?: string;
327
+ isError?: boolean;
328
+ durationMs?: number;
329
+ truncated?: {
330
+ args?: boolean;
331
+ progress?: boolean;
332
+ result?: boolean;
333
+ };
334
+ ts?: number;
335
+ seq?: number;
336
+ };
337
+ /**
338
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
339
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
340
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
341
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
342
+ * event already provides one (title/summary/label/description), trimmed.
343
+ */
344
+ declare function buildToolActivity(data: Record<string, unknown>): ChatActivity | null;
277
345
  declare function stripLeakedToolSummary(text: string): string;
278
346
  /**
279
347
  * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
@@ -328,4 +396,4 @@ declare const plugin: {
328
396
  deactivate(api: PluginApi): Promise<void>;
329
397
  };
330
398
  //#endregion
331
- export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
399
+ export { plugin as a, AlfeChannelConfig as c, buildToolActivity as i, AlfePluginConfig as l, admitAgentEvent as n, stripLeakedToolSummary as o, buildA2ACompletePayload as r, createAlfeChannelPlugin as s, RunEventGate as t, AlfeResolvedAccount as u };
package/dist/plugin.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin2.js";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { a as stripLeakedToolSummary, i as plugin, n as buildA2ACompletePayload, r as buildToolActivity, t as admitAgentEvent } from "./plugin2.js";
2
+ export { admitAgentEvent, buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.cjs CHANGED
@@ -619,6 +619,235 @@ function validateAttachmentUrl(input, opts = {}) {
619
619
  };
620
620
  }
621
621
  //#endregion
622
+ //#region src/activity-serialize.ts
623
+ /**
624
+ * Console-grade tool-activity serialization (Slice 3).
625
+ *
626
+ * The chat plugin forwards a curated activity stream to the chat service.
627
+ * For tool calls we now carry the args / streaming progress / final result
628
+ * so the client can render an OpenClaw-console-grade card. Those payloads
629
+ * arrive from OpenClaw's `onAgentEvent` bus as arbitrary `unknown`-shaped
630
+ * objects (`args` is the raw tool-call arguments; `partialResult` / `result`
631
+ * are tool-result envelopes). We must render them to BOUNDED strings BEFORE
632
+ * they cross the WS to the chat service, because:
633
+ *
634
+ * 1. Daemon WS frames are `maxPayload`-bounded — an uncapped multi-MB tool
635
+ * stdout would drop the WHOLE frame (losing the status transition too),
636
+ * so the cap has to live on the emit side, not the client.
637
+ * 2. The relay is a dumb passthrough and the reducer is pure — neither
638
+ * should have to serialize or sanitize unknown objects. The wire carries
639
+ * display text, never raw objects.
640
+ *
641
+ * OpenClaw's own `sanitizeToolResult` already strips standard image content
642
+ * blocks (`{type:'image', data}` → `{omitted:true}`) and truncates text on
643
+ * the `result`/`partialResult` path — but (a) its helpers are NOT importable
644
+ * from the plugin (openclaw is a runtime peer dep, absent from node_modules),
645
+ * and (b) `args` is NOT sanitized at all and can carry base64 (an inline
646
+ * image arg, a `data:` URI in a write). So we reimplement a defensive
647
+ * strip+cap here and apply it to every field, regardless of upstream
648
+ * sanitization.
649
+ */
650
+ /** Args render cap — args are usually small; 4KB is generous. */
651
+ const ARGS_CAP = 4 * 1024;
652
+ /** Streaming progress window — keep the newest 4KB (tail-truncated). */
653
+ const PROGRESS_CAP = 4 * 1024;
654
+ /** Final result cap — results can be large (file reads, search); 16KB. */
655
+ const RESULT_CAP = 16 * 1024;
656
+ /**
657
+ * Replace long base64 runs, `data:` URIs, and known image content-block
658
+ * shapes with a placeholder. Runs BEFORE JSON serialization on a deep-cloned
659
+ * structure so we never mutate OpenClaw's live objects.
660
+ *
661
+ * Detection heuristics (all conservative — we only replace clear payloads):
662
+ * - A string that is a `data:` URI → `[data-uri omitted]`.
663
+ * - A string that is a long (≥256 char) run of base64 chars → `[base64 omitted]`.
664
+ * 256 is well above any realistic id/token/hash and below inline media.
665
+ * - An object that looks like an image content block (`type:'image'` with a
666
+ * `data`/`source`/`url` field, OR an Anthropic `{source:{data}}` shape) →
667
+ * the payload field is replaced with `[image omitted]`.
668
+ */
669
+ const DATA_URI_RE = /^data:[^;,]*(;[^,]*)?,/i;
670
+ const BASE64_BLOB_RE = /^[A-Za-z0-9+/=\r\n]+$/;
671
+ const BASE64_MIN_LEN = 256;
672
+ function looksLikeBase64Blob(s) {
673
+ if (s.length < BASE64_MIN_LEN) return false;
674
+ return s.replace(/[\r\n]/g, "").length >= BASE64_MIN_LEN && BASE64_BLOB_RE.test(s);
675
+ }
676
+ const EMBEDDED_DATA_URI_RE = /data:[^;,\s"'`]{0,64}(?:;[^,\s"'`]{0,32})?,[A-Za-z0-9+/=]{128,}/g;
677
+ const EMBEDDED_BASE64_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
678
+ /**
679
+ * Post-match filter for EMBEDDED_BASE64_RE: real base64 media is
680
+ * high-entropy — a 512+ char run virtually always mixes lower, upper, and
681
+ * digits. This keeps legitimate long runs (a repeated fill char in test
682
+ * output, an ASCII divider) from being eaten by the strip.
683
+ */
684
+ function looksHighEntropyBase64(run) {
685
+ return /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run);
686
+ }
687
+ function stripStringMedia(s) {
688
+ if (DATA_URI_RE.test(s)) return "[data-uri omitted]";
689
+ if (looksLikeBase64Blob(s)) return "[base64 omitted]";
690
+ if (s.length >= 128) return s.replace(EMBEDDED_DATA_URI_RE, "[data-uri omitted]").replace(EMBEDDED_BASE64_RE, (m) => looksHighEntropyBase64(m) ? "[base64 omitted]" : m);
691
+ return s;
692
+ }
693
+ const MEDIA_PAYLOAD_KEYS = new Set([
694
+ "data",
695
+ "base64",
696
+ "b64_json",
697
+ "bytes"
698
+ ]);
699
+ function isImageBlock(obj) {
700
+ const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
701
+ if (type === "image" || type === "image_url" || type === "input_image") return true;
702
+ if (obj.source && typeof obj.source === "object") {
703
+ const src = obj.source;
704
+ if (typeof src.data === "string" || typeof src.url === "string") return true;
705
+ }
706
+ return false;
707
+ }
708
+ /**
709
+ * Deep-clone `value` while stripping media. Guards against cycles (returns
710
+ * `'[circular]'`) and caps recursion depth. Non-plain values (functions,
711
+ * symbols) are dropped by JSON semantics later; here we normalize them so the
712
+ * safe-JSON pass can't throw.
713
+ */
714
+ function stripMediaDeep(value, seen, depth) {
715
+ if (depth > 12) return "[max-depth]";
716
+ if (value === null) return null;
717
+ const t = typeof value;
718
+ if (t === "string") return stripStringMedia(value);
719
+ if (t === "number" || t === "boolean") return value;
720
+ if (t === "bigint") return `${value.toString()}n`;
721
+ if (t === "undefined" || t === "function" || t === "symbol") return void 0;
722
+ const obj = value;
723
+ if (seen.has(obj)) return "[circular]";
724
+ seen.add(obj);
725
+ try {
726
+ if (Array.isArray(obj)) return obj.map((item) => stripMediaDeep(item, seen, depth + 1));
727
+ const record = obj;
728
+ const imageBlock = isImageBlock(record);
729
+ const out = {};
730
+ for (const [key, v] of Object.entries(record)) {
731
+ if (imageBlock && MEDIA_PAYLOAD_KEYS.has(key)) {
732
+ out[key] = "[image omitted]";
733
+ continue;
734
+ }
735
+ if (imageBlock && key === "source" && v && typeof v === "object") {
736
+ const src = { ...v };
737
+ for (const mk of MEDIA_PAYLOAD_KEYS) if (typeof src[mk] === "string") src[mk] = "[image omitted]";
738
+ out[key] = src;
739
+ continue;
740
+ }
741
+ out[key] = stripMediaDeep(v, seen, depth + 1);
742
+ }
743
+ return out;
744
+ } finally {
745
+ seen.delete(obj);
746
+ }
747
+ }
748
+ /**
749
+ * Cycle- and BigInt-safe JSON stringify. Returns `undefined` when there's
750
+ * nothing meaningful to render (null/undefined). A plain string value is
751
+ * returned as-is (unquoted) — args like `"ls -la"` read better raw than
752
+ * `"\"ls -la\""`.
753
+ */
754
+ function safeJson(value) {
755
+ if (value === null || value === void 0) return void 0;
756
+ const stripped = stripMediaDeep(value, /* @__PURE__ */ new WeakSet(), 0);
757
+ if (stripped === void 0) return void 0;
758
+ if (typeof stripped === "string") return stripped;
759
+ try {
760
+ return JSON.stringify(stripped, null, 2);
761
+ } catch {
762
+ return "[unserializable]";
763
+ }
764
+ }
765
+ /** Head-truncate: keep the START of the text (for args/results). */
766
+ function capHead(text, cap) {
767
+ if (text.length <= cap) return {
768
+ text,
769
+ truncated: false
770
+ };
771
+ return {
772
+ text: `${text.slice(0, cap)}\n… [truncated ${String(text.length - cap)} chars]`,
773
+ truncated: true
774
+ };
775
+ }
776
+ /** Tail-truncate: keep the END of the text (for streaming progress). */
777
+ function capTail(text, cap) {
778
+ if (text.length <= cap) return {
779
+ text,
780
+ truncated: false
781
+ };
782
+ return {
783
+ text: `… [truncated ${String(text.length - cap)} chars]\n${text.slice(text.length - cap)}`,
784
+ truncated: true
785
+ };
786
+ }
787
+ /**
788
+ * Serialize + strip + head-cap the tool-call `args` (from `phase:start`).
789
+ * Returns `undefined` when there's nothing to show.
790
+ */
791
+ function serializeArgs(args) {
792
+ const rendered = safeJson(args);
793
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
794
+ return capHead(rendered, ARGS_CAP);
795
+ }
796
+ /**
797
+ * Serialize + strip + head-cap the final tool `result` (from `phase:result`).
798
+ * Prefers rendering the result's text content when it's a standard
799
+ * `{content:[{type:'text',text}]}` envelope (reads far better than the raw
800
+ * JSON), falling back to the full JSON otherwise.
801
+ */
802
+ function serializeResult(result) {
803
+ if (isEmptyEnvelope(result)) return void 0;
804
+ const rendered = extractResultText(result) ?? safeJson(result);
805
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
806
+ return capHead(stripStringMedia(rendered), RESULT_CAP);
807
+ }
808
+ /**
809
+ * Serialize + strip + TAIL-cap a streaming `partialResult` (from
810
+ * `phase:update`). Tail-truncation keeps the NEWEST output — for a bash/exec
811
+ * stream the tail is what the user cares about. Whether OpenClaw's
812
+ * `partialResult` is cumulative or incremental is version-dependent; treating
813
+ * it as "the latest window" (overwrite, not append — see the reducer) is
814
+ * correct for both: a cumulative stream's newest frame already contains the
815
+ * full window (tail-capped), and an incremental stream's newest frame is the
816
+ * latest chunk.
817
+ */
818
+ function serializeProgress(partial) {
819
+ if (isEmptyEnvelope(partial)) return void 0;
820
+ const rendered = extractResultText(partial) ?? safeJson(partial);
821
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
822
+ return capTail(stripStringMedia(rendered), PROGRESS_CAP);
823
+ }
824
+ /**
825
+ * Pull the concatenated `text` blocks out of a standard tool-result envelope
826
+ * (`{content:[{type:'text',text}, {type:'image',omitted:true}]}`). Returns
827
+ * `undefined` when the shape isn't a text-bearing envelope, so callers fall
828
+ * back to full-JSON rendering.
829
+ */
830
+ /** True for a tool-result envelope with an empty `content` array. */
831
+ function isEmptyEnvelope(value) {
832
+ if (!value || typeof value !== "object") return false;
833
+ const content = value.content;
834
+ return Array.isArray(content) && content.length === 0;
835
+ }
836
+ function extractResultText(result) {
837
+ if (!result || typeof result !== "object") return void 0;
838
+ const content = result.content;
839
+ if (!Array.isArray(content)) return void 0;
840
+ const parts = [];
841
+ for (const item of content) {
842
+ if (!item || typeof item !== "object") continue;
843
+ const entry = item;
844
+ if (entry.type === "text" && typeof entry.text === "string") parts.push(entry.text);
845
+ else if (entry.type === "image" || entry.omitted === true) parts.push("[image omitted]");
846
+ }
847
+ if (parts.length === 0) return void 0;
848
+ return parts.join("\n");
849
+ }
850
+ //#endregion
622
851
  //#region src/plugin.ts
623
852
  /**
624
853
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -635,6 +864,45 @@ function validateAttachmentUrl(input, opts = {}) {
635
864
  */
636
865
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
637
866
  const pkg = require$1("../package.json");
867
+ /**
868
+ * Decide whether an agent-event belongs to THIS turn's run and should be
869
+ * forwarded as a delta / activity frame.
870
+ *
871
+ * Why gate on `runId`, not `sessionKey`:
872
+ * On OpenClaw 2026.x the daemon registers this ('alfe') channel's main run
873
+ * with `isControlUiVisible: false` — only the built-in "webchat" surface is
874
+ * treated as control-UI-visible (`isInternalMessageChannel`). The shared
875
+ * agent-event bus then NULLS `sessionKey` on every event of a
876
+ * non-control-UI run (`agent-events`: `sessionKey = isControlUiVisible ? … :
877
+ * undefined`). So main-run assistant/thinking/tool events arrive with
878
+ * `sessionKey: undefined`, and the old `!evt.sessionKey` + key-equality gate
879
+ * silently dropped ALL activity — replies still landed because they come via
880
+ * the `deliver()` callback, not the event bus (the reported "replies arrive,
881
+ * zero activity" symptom). Verified against openclaw@2026.6.10 and
882
+ * back-compatible with 2026.4.2 (identical emit semantics). `runId` is always
883
+ * present on every emitted event.
884
+ *
885
+ * Isolation is preserved: turns to a daemon are serialised
886
+ * (`enqueueAgentTurn`) and the listener is torn down before the next turn
887
+ * dispatches, so the first relevant event locks onto THIS turn's main run.
888
+ * Concurrent heartbeat/cron runs and subagent / side-question runs carry a
889
+ * different `runId` and are excluded — exactly the leakage the gate exists to
890
+ * prevent. When a run IS control-UI-visible and both keys are present, a
891
+ * mismatched `sessionKey` is still rejected as a secondary defence.
892
+ *
893
+ * Exported for unit tests — this gate is the fix's load-bearing logic and is
894
+ * pinned against real 2026.6.10 event shapes.
895
+ */
896
+ function admitAgentEvent(gate, evt) {
897
+ if (!(evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool")) return false;
898
+ if (gate.runId === null) {
899
+ gate.runId = evt.runId;
900
+ if (evt.sessionKey) gate.sessionKey ??= evt.sessionKey;
901
+ }
902
+ if (evt.runId !== gate.runId) return false;
903
+ if (gate.sessionKey && evt.sessionKey && evt.sessionKey !== gate.sessionKey) return false;
904
+ return true;
905
+ }
638
906
  function asString(v) {
639
907
  return typeof v === "string" && v.length > 0 ? v : void 0;
640
908
  }
@@ -668,17 +936,58 @@ function buildToolActivity(data) {
668
936
  const rawStatus = (phase ?? "").toLowerCase();
669
937
  let status;
670
938
  if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
671
- else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
939
+ else if (rawStatus === "result" || rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
672
940
  else status = "running";
673
941
  const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
674
942
  const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
943
+ const truncated = {};
944
+ let argsText;
945
+ let progressText;
946
+ let resultText;
947
+ let isError;
948
+ if (rawStatus === "start" || rawStatus === "running" || rawStatus === "") {
949
+ const a = serializeArgs(data.args ?? data.arguments ?? data.input);
950
+ if (a) {
951
+ argsText = a.text;
952
+ if (a.truncated) truncated.args = true;
953
+ }
954
+ }
955
+ const partial = data.partialResult ?? data.partial ?? data.delta;
956
+ if (partial !== void 0) {
957
+ const p = serializeProgress(partial);
958
+ if (p) {
959
+ progressText = p.text;
960
+ if (p.truncated) truncated.progress = true;
961
+ }
962
+ }
963
+ const resultRaw = data.result ?? data.output;
964
+ if (resultRaw !== void 0 || status === "done" || status === "failed") {
965
+ if (resultRaw !== void 0) {
966
+ const r = serializeResult(resultRaw);
967
+ if (r) {
968
+ resultText = r.text;
969
+ if (r.truncated) truncated.result = true;
970
+ }
971
+ }
972
+ if (data.isError === true || status === "failed") isError = true;
973
+ else if (data.isError === false) isError = false;
974
+ }
975
+ const durationRaw = data.durationMs ?? data.duration;
976
+ const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
977
+ const hasTruncated = truncated.args === true || truncated.progress === true || truncated.result === true;
675
978
  return {
676
979
  kind: "tool",
677
980
  toolCallId,
678
981
  name,
679
982
  ...phase ? { phase } : {},
680
983
  status,
681
- ...summary ? { summary } : {}
984
+ ...summary ? { summary } : {},
985
+ ...argsText !== void 0 ? { argsText } : {},
986
+ ...progressText !== void 0 ? { progressText } : {},
987
+ ...resultText !== void 0 ? { resultText } : {},
988
+ ...isError !== void 0 ? { isError } : {},
989
+ ...durationMs !== void 0 ? { durationMs } : {},
990
+ ...hasTruncated ? { truncated } : {}
682
991
  };
683
992
  }
684
993
  /**
@@ -858,11 +1167,25 @@ async function handleAgentRequest(request, log) {
858
1167
  const sessionId = conversationId ?? legacySessionKey;
859
1168
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
860
1169
  await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
861
- let resolvedOpenClawKey = null;
1170
+ const runGate = {
1171
+ runId: null,
1172
+ sessionKey: null
1173
+ };
1174
+ const UPDATE_THROTTLE_MS = 250;
1175
+ const pendingUpdates = /* @__PURE__ */ new Map();
1176
+ const flushToolUpdate = (toolCallId) => {
1177
+ const pending = pendingUpdates.get(toolCallId);
1178
+ if (!pending) return;
1179
+ clearTimeout(pending.timer);
1180
+ pendingUpdates.delete(toolCallId);
1181
+ pending.send();
1182
+ };
1183
+ const clearAllToolUpdates = () => {
1184
+ for (const pending of pendingUpdates.values()) clearTimeout(pending.timer);
1185
+ pendingUpdates.clear();
1186
+ };
862
1187
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
863
- if (!evt.sessionKey) return;
864
- if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
865
- if (evt.sessionKey !== resolvedOpenClawKey) return;
1188
+ if (!admitAgentEvent(runGate, evt)) return;
866
1189
  if (evt.stream === "assistant") {
867
1190
  chatClient?.sendEvent("chat", {
868
1191
  runId: evt.runId,
@@ -890,17 +1213,38 @@ async function handleAgentRequest(request, log) {
890
1213
  }
891
1214
  if (evt.stream === "tool") {
892
1215
  const activity = buildToolActivity(evt.data);
893
- if (activity) chatClient?.sendEvent("chat-activity", {
894
- runId: evt.runId,
895
- sessionKey: legacySessionKey,
896
- seq: evt.seq,
897
- state: "activity",
898
- activity: {
899
- ...activity,
900
- ts: evt.ts,
901
- seq: evt.seq
1216
+ if (activity?.kind !== "tool") return;
1217
+ const toolCallId = activity.toolCallId;
1218
+ const send = () => {
1219
+ chatClient?.sendEvent("chat-activity", {
1220
+ runId: evt.runId,
1221
+ sessionKey: legacySessionKey,
1222
+ seq: evt.seq,
1223
+ state: "activity",
1224
+ activity: {
1225
+ ...activity,
1226
+ ts: evt.ts,
1227
+ seq: evt.seq
1228
+ }
1229
+ });
1230
+ };
1231
+ const rawPhase = (activity.phase ?? "").toLowerCase();
1232
+ if (rawPhase === "update" || activity.status === "running" && rawPhase !== "start" && rawPhase !== "") {
1233
+ const existing = pendingUpdates.get(toolCallId);
1234
+ if (existing) existing.send = send;
1235
+ else {
1236
+ const timer = setTimeout(() => {
1237
+ flushToolUpdate(toolCallId);
1238
+ }, UPDATE_THROTTLE_MS);
1239
+ pendingUpdates.set(toolCallId, {
1240
+ timer,
1241
+ send
1242
+ });
902
1243
  }
903
- });
1244
+ return;
1245
+ }
1246
+ flushToolUpdate(toolCallId);
1247
+ send();
904
1248
  return;
905
1249
  }
906
1250
  });
@@ -919,7 +1263,7 @@ async function handleAgentRequest(request, log) {
919
1263
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
920
1264
  let a2aResponseBuffer = "";
921
1265
  if (isA2A) resetA2AEndSignal();
922
- resolvedOpenClawKey = (await dispatchInbound({
1266
+ runGate.sessionKey = (await dispatchInbound({
923
1267
  cfg,
924
1268
  runtime: { channel: runtime.channel },
925
1269
  channel: "alfe",
@@ -972,7 +1316,7 @@ async function handleAgentRequest(request, log) {
972
1316
  chatClient?.notify("agent-message", {
973
1317
  conversationId: conversationId ?? legacySessionKey,
974
1318
  text: responseText,
975
- sessionKey: resolvedOpenClawKey ?? legacySessionKey,
1319
+ sessionKey: runGate.sessionKey ?? legacySessionKey,
976
1320
  messageId: chatMessageId ?? request.id,
977
1321
  ...mediaUrls.length ? { mediaUrls } : {}
978
1322
  });
@@ -993,14 +1337,15 @@ async function handleAgentRequest(request, log) {
993
1337
  resolved: a2aResolved
994
1338
  }));
995
1339
  }
996
- chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
997
- log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
1340
+ chatClient?.sendResponse(request.id, true, { sessionKey: runGate.sessionKey });
1341
+ log.info(`Agent dispatch complete: sessionKey=${runGate.sessionKey}`);
998
1342
  } catch (err) {
999
1343
  const errMsg = err instanceof Error ? err.message : String(err);
1000
1344
  log.error(`Agent dispatch failed: ${errMsg}`);
1001
1345
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1002
1346
  } finally {
1003
1347
  unsubscribe();
1348
+ clearAllToolUpdates();
1004
1349
  if (isA2A) disarmA2AEndSignal();
1005
1350
  }
1006
1351
  }
@@ -1229,12 +1574,24 @@ const plugin = {
1229
1574
  }
1230
1575
  };
1231
1576
  //#endregion
1577
+ Object.defineProperty(exports, "admitAgentEvent", {
1578
+ enumerable: true,
1579
+ get: function() {
1580
+ return admitAgentEvent;
1581
+ }
1582
+ });
1232
1583
  Object.defineProperty(exports, "buildA2ACompletePayload", {
1233
1584
  enumerable: true,
1234
1585
  get: function() {
1235
1586
  return buildA2ACompletePayload;
1236
1587
  }
1237
1588
  });
1589
+ Object.defineProperty(exports, "buildToolActivity", {
1590
+ enumerable: true,
1591
+ get: function() {
1592
+ return buildToolActivity;
1593
+ }
1594
+ });
1238
1595
  Object.defineProperty(exports, "createAlfeChannelPlugin", {
1239
1596
  enumerable: true,
1240
1597
  get: function() {
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.cjs";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { a as plugin, i as buildToolActivity, n as admitAgentEvent, o as stripLeakedToolSummary, r as buildA2ACompletePayload, t as RunEventGate } from "./plugin.cjs";
2
+ export { RunEventGate, admitAgentEvent, buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.js";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { a as plugin, i as buildToolActivity, n as admitAgentEvent, o as stripLeakedToolSummary, r as buildA2ACompletePayload, t as RunEventGate } from "./plugin.js";
2
+ export { RunEventGate, admitAgentEvent, buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.js CHANGED
@@ -619,6 +619,235 @@ function validateAttachmentUrl(input, opts = {}) {
619
619
  };
620
620
  }
621
621
  //#endregion
622
+ //#region src/activity-serialize.ts
623
+ /**
624
+ * Console-grade tool-activity serialization (Slice 3).
625
+ *
626
+ * The chat plugin forwards a curated activity stream to the chat service.
627
+ * For tool calls we now carry the args / streaming progress / final result
628
+ * so the client can render an OpenClaw-console-grade card. Those payloads
629
+ * arrive from OpenClaw's `onAgentEvent` bus as arbitrary `unknown`-shaped
630
+ * objects (`args` is the raw tool-call arguments; `partialResult` / `result`
631
+ * are tool-result envelopes). We must render them to BOUNDED strings BEFORE
632
+ * they cross the WS to the chat service, because:
633
+ *
634
+ * 1. Daemon WS frames are `maxPayload`-bounded — an uncapped multi-MB tool
635
+ * stdout would drop the WHOLE frame (losing the status transition too),
636
+ * so the cap has to live on the emit side, not the client.
637
+ * 2. The relay is a dumb passthrough and the reducer is pure — neither
638
+ * should have to serialize or sanitize unknown objects. The wire carries
639
+ * display text, never raw objects.
640
+ *
641
+ * OpenClaw's own `sanitizeToolResult` already strips standard image content
642
+ * blocks (`{type:'image', data}` → `{omitted:true}`) and truncates text on
643
+ * the `result`/`partialResult` path — but (a) its helpers are NOT importable
644
+ * from the plugin (openclaw is a runtime peer dep, absent from node_modules),
645
+ * and (b) `args` is NOT sanitized at all and can carry base64 (an inline
646
+ * image arg, a `data:` URI in a write). So we reimplement a defensive
647
+ * strip+cap here and apply it to every field, regardless of upstream
648
+ * sanitization.
649
+ */
650
+ /** Args render cap — args are usually small; 4KB is generous. */
651
+ const ARGS_CAP = 4 * 1024;
652
+ /** Streaming progress window — keep the newest 4KB (tail-truncated). */
653
+ const PROGRESS_CAP = 4 * 1024;
654
+ /** Final result cap — results can be large (file reads, search); 16KB. */
655
+ const RESULT_CAP = 16 * 1024;
656
+ /**
657
+ * Replace long base64 runs, `data:` URIs, and known image content-block
658
+ * shapes with a placeholder. Runs BEFORE JSON serialization on a deep-cloned
659
+ * structure so we never mutate OpenClaw's live objects.
660
+ *
661
+ * Detection heuristics (all conservative — we only replace clear payloads):
662
+ * - A string that is a `data:` URI → `[data-uri omitted]`.
663
+ * - A string that is a long (≥256 char) run of base64 chars → `[base64 omitted]`.
664
+ * 256 is well above any realistic id/token/hash and below inline media.
665
+ * - An object that looks like an image content block (`type:'image'` with a
666
+ * `data`/`source`/`url` field, OR an Anthropic `{source:{data}}` shape) →
667
+ * the payload field is replaced with `[image omitted]`.
668
+ */
669
+ const DATA_URI_RE = /^data:[^;,]*(;[^,]*)?,/i;
670
+ const BASE64_BLOB_RE = /^[A-Za-z0-9+/=\r\n]+$/;
671
+ const BASE64_MIN_LEN = 256;
672
+ function looksLikeBase64Blob(s) {
673
+ if (s.length < BASE64_MIN_LEN) return false;
674
+ return s.replace(/[\r\n]/g, "").length >= BASE64_MIN_LEN && BASE64_BLOB_RE.test(s);
675
+ }
676
+ const EMBEDDED_DATA_URI_RE = /data:[^;,\s"'`]{0,64}(?:;[^,\s"'`]{0,32})?,[A-Za-z0-9+/=]{128,}/g;
677
+ const EMBEDDED_BASE64_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
678
+ /**
679
+ * Post-match filter for EMBEDDED_BASE64_RE: real base64 media is
680
+ * high-entropy — a 512+ char run virtually always mixes lower, upper, and
681
+ * digits. This keeps legitimate long runs (a repeated fill char in test
682
+ * output, an ASCII divider) from being eaten by the strip.
683
+ */
684
+ function looksHighEntropyBase64(run) {
685
+ return /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run);
686
+ }
687
+ function stripStringMedia(s) {
688
+ if (DATA_URI_RE.test(s)) return "[data-uri omitted]";
689
+ if (looksLikeBase64Blob(s)) return "[base64 omitted]";
690
+ if (s.length >= 128) return s.replace(EMBEDDED_DATA_URI_RE, "[data-uri omitted]").replace(EMBEDDED_BASE64_RE, (m) => looksHighEntropyBase64(m) ? "[base64 omitted]" : m);
691
+ return s;
692
+ }
693
+ const MEDIA_PAYLOAD_KEYS = new Set([
694
+ "data",
695
+ "base64",
696
+ "b64_json",
697
+ "bytes"
698
+ ]);
699
+ function isImageBlock(obj) {
700
+ const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
701
+ if (type === "image" || type === "image_url" || type === "input_image") return true;
702
+ if (obj.source && typeof obj.source === "object") {
703
+ const src = obj.source;
704
+ if (typeof src.data === "string" || typeof src.url === "string") return true;
705
+ }
706
+ return false;
707
+ }
708
+ /**
709
+ * Deep-clone `value` while stripping media. Guards against cycles (returns
710
+ * `'[circular]'`) and caps recursion depth. Non-plain values (functions,
711
+ * symbols) are dropped by JSON semantics later; here we normalize them so the
712
+ * safe-JSON pass can't throw.
713
+ */
714
+ function stripMediaDeep(value, seen, depth) {
715
+ if (depth > 12) return "[max-depth]";
716
+ if (value === null) return null;
717
+ const t = typeof value;
718
+ if (t === "string") return stripStringMedia(value);
719
+ if (t === "number" || t === "boolean") return value;
720
+ if (t === "bigint") return `${value.toString()}n`;
721
+ if (t === "undefined" || t === "function" || t === "symbol") return void 0;
722
+ const obj = value;
723
+ if (seen.has(obj)) return "[circular]";
724
+ seen.add(obj);
725
+ try {
726
+ if (Array.isArray(obj)) return obj.map((item) => stripMediaDeep(item, seen, depth + 1));
727
+ const record = obj;
728
+ const imageBlock = isImageBlock(record);
729
+ const out = {};
730
+ for (const [key, v] of Object.entries(record)) {
731
+ if (imageBlock && MEDIA_PAYLOAD_KEYS.has(key)) {
732
+ out[key] = "[image omitted]";
733
+ continue;
734
+ }
735
+ if (imageBlock && key === "source" && v && typeof v === "object") {
736
+ const src = { ...v };
737
+ for (const mk of MEDIA_PAYLOAD_KEYS) if (typeof src[mk] === "string") src[mk] = "[image omitted]";
738
+ out[key] = src;
739
+ continue;
740
+ }
741
+ out[key] = stripMediaDeep(v, seen, depth + 1);
742
+ }
743
+ return out;
744
+ } finally {
745
+ seen.delete(obj);
746
+ }
747
+ }
748
+ /**
749
+ * Cycle- and BigInt-safe JSON stringify. Returns `undefined` when there's
750
+ * nothing meaningful to render (null/undefined). A plain string value is
751
+ * returned as-is (unquoted) — args like `"ls -la"` read better raw than
752
+ * `"\"ls -la\""`.
753
+ */
754
+ function safeJson(value) {
755
+ if (value === null || value === void 0) return void 0;
756
+ const stripped = stripMediaDeep(value, /* @__PURE__ */ new WeakSet(), 0);
757
+ if (stripped === void 0) return void 0;
758
+ if (typeof stripped === "string") return stripped;
759
+ try {
760
+ return JSON.stringify(stripped, null, 2);
761
+ } catch {
762
+ return "[unserializable]";
763
+ }
764
+ }
765
+ /** Head-truncate: keep the START of the text (for args/results). */
766
+ function capHead(text, cap) {
767
+ if (text.length <= cap) return {
768
+ text,
769
+ truncated: false
770
+ };
771
+ return {
772
+ text: `${text.slice(0, cap)}\n… [truncated ${String(text.length - cap)} chars]`,
773
+ truncated: true
774
+ };
775
+ }
776
+ /** Tail-truncate: keep the END of the text (for streaming progress). */
777
+ function capTail(text, cap) {
778
+ if (text.length <= cap) return {
779
+ text,
780
+ truncated: false
781
+ };
782
+ return {
783
+ text: `… [truncated ${String(text.length - cap)} chars]\n${text.slice(text.length - cap)}`,
784
+ truncated: true
785
+ };
786
+ }
787
+ /**
788
+ * Serialize + strip + head-cap the tool-call `args` (from `phase:start`).
789
+ * Returns `undefined` when there's nothing to show.
790
+ */
791
+ function serializeArgs(args) {
792
+ const rendered = safeJson(args);
793
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
794
+ return capHead(rendered, ARGS_CAP);
795
+ }
796
+ /**
797
+ * Serialize + strip + head-cap the final tool `result` (from `phase:result`).
798
+ * Prefers rendering the result's text content when it's a standard
799
+ * `{content:[{type:'text',text}]}` envelope (reads far better than the raw
800
+ * JSON), falling back to the full JSON otherwise.
801
+ */
802
+ function serializeResult(result) {
803
+ if (isEmptyEnvelope(result)) return void 0;
804
+ const rendered = extractResultText(result) ?? safeJson(result);
805
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
806
+ return capHead(stripStringMedia(rendered), RESULT_CAP);
807
+ }
808
+ /**
809
+ * Serialize + strip + TAIL-cap a streaming `partialResult` (from
810
+ * `phase:update`). Tail-truncation keeps the NEWEST output — for a bash/exec
811
+ * stream the tail is what the user cares about. Whether OpenClaw's
812
+ * `partialResult` is cumulative or incremental is version-dependent; treating
813
+ * it as "the latest window" (overwrite, not append — see the reducer) is
814
+ * correct for both: a cumulative stream's newest frame already contains the
815
+ * full window (tail-capped), and an incremental stream's newest frame is the
816
+ * latest chunk.
817
+ */
818
+ function serializeProgress(partial) {
819
+ if (isEmptyEnvelope(partial)) return void 0;
820
+ const rendered = extractResultText(partial) ?? safeJson(partial);
821
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
822
+ return capTail(stripStringMedia(rendered), PROGRESS_CAP);
823
+ }
824
+ /**
825
+ * Pull the concatenated `text` blocks out of a standard tool-result envelope
826
+ * (`{content:[{type:'text',text}, {type:'image',omitted:true}]}`). Returns
827
+ * `undefined` when the shape isn't a text-bearing envelope, so callers fall
828
+ * back to full-JSON rendering.
829
+ */
830
+ /** True for a tool-result envelope with an empty `content` array. */
831
+ function isEmptyEnvelope(value) {
832
+ if (!value || typeof value !== "object") return false;
833
+ const content = value.content;
834
+ return Array.isArray(content) && content.length === 0;
835
+ }
836
+ function extractResultText(result) {
837
+ if (!result || typeof result !== "object") return void 0;
838
+ const content = result.content;
839
+ if (!Array.isArray(content)) return void 0;
840
+ const parts = [];
841
+ for (const item of content) {
842
+ if (!item || typeof item !== "object") continue;
843
+ const entry = item;
844
+ if (entry.type === "text" && typeof entry.text === "string") parts.push(entry.text);
845
+ else if (entry.type === "image" || entry.omitted === true) parts.push("[image omitted]");
846
+ }
847
+ if (parts.length === 0) return void 0;
848
+ return parts.join("\n");
849
+ }
850
+ //#endregion
622
851
  //#region src/plugin.ts
623
852
  /**
624
853
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -635,6 +864,45 @@ function validateAttachmentUrl(input, opts = {}) {
635
864
  */
636
865
  const require = createRequire(import.meta.url);
637
866
  const pkg = require("../package.json");
867
+ /**
868
+ * Decide whether an agent-event belongs to THIS turn's run and should be
869
+ * forwarded as a delta / activity frame.
870
+ *
871
+ * Why gate on `runId`, not `sessionKey`:
872
+ * On OpenClaw 2026.x the daemon registers this ('alfe') channel's main run
873
+ * with `isControlUiVisible: false` — only the built-in "webchat" surface is
874
+ * treated as control-UI-visible (`isInternalMessageChannel`). The shared
875
+ * agent-event bus then NULLS `sessionKey` on every event of a
876
+ * non-control-UI run (`agent-events`: `sessionKey = isControlUiVisible ? … :
877
+ * undefined`). So main-run assistant/thinking/tool events arrive with
878
+ * `sessionKey: undefined`, and the old `!evt.sessionKey` + key-equality gate
879
+ * silently dropped ALL activity — replies still landed because they come via
880
+ * the `deliver()` callback, not the event bus (the reported "replies arrive,
881
+ * zero activity" symptom). Verified against openclaw@2026.6.10 and
882
+ * back-compatible with 2026.4.2 (identical emit semantics). `runId` is always
883
+ * present on every emitted event.
884
+ *
885
+ * Isolation is preserved: turns to a daemon are serialised
886
+ * (`enqueueAgentTurn`) and the listener is torn down before the next turn
887
+ * dispatches, so the first relevant event locks onto THIS turn's main run.
888
+ * Concurrent heartbeat/cron runs and subagent / side-question runs carry a
889
+ * different `runId` and are excluded — exactly the leakage the gate exists to
890
+ * prevent. When a run IS control-UI-visible and both keys are present, a
891
+ * mismatched `sessionKey` is still rejected as a secondary defence.
892
+ *
893
+ * Exported for unit tests — this gate is the fix's load-bearing logic and is
894
+ * pinned against real 2026.6.10 event shapes.
895
+ */
896
+ function admitAgentEvent(gate, evt) {
897
+ if (!(evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool")) return false;
898
+ if (gate.runId === null) {
899
+ gate.runId = evt.runId;
900
+ if (evt.sessionKey) gate.sessionKey ??= evt.sessionKey;
901
+ }
902
+ if (evt.runId !== gate.runId) return false;
903
+ if (gate.sessionKey && evt.sessionKey && evt.sessionKey !== gate.sessionKey) return false;
904
+ return true;
905
+ }
638
906
  function asString(v) {
639
907
  return typeof v === "string" && v.length > 0 ? v : void 0;
640
908
  }
@@ -668,17 +936,58 @@ function buildToolActivity(data) {
668
936
  const rawStatus = (phase ?? "").toLowerCase();
669
937
  let status;
670
938
  if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
671
- else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
939
+ else if (rawStatus === "result" || rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
672
940
  else status = "running";
673
941
  const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
674
942
  const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
943
+ const truncated = {};
944
+ let argsText;
945
+ let progressText;
946
+ let resultText;
947
+ let isError;
948
+ if (rawStatus === "start" || rawStatus === "running" || rawStatus === "") {
949
+ const a = serializeArgs(data.args ?? data.arguments ?? data.input);
950
+ if (a) {
951
+ argsText = a.text;
952
+ if (a.truncated) truncated.args = true;
953
+ }
954
+ }
955
+ const partial = data.partialResult ?? data.partial ?? data.delta;
956
+ if (partial !== void 0) {
957
+ const p = serializeProgress(partial);
958
+ if (p) {
959
+ progressText = p.text;
960
+ if (p.truncated) truncated.progress = true;
961
+ }
962
+ }
963
+ const resultRaw = data.result ?? data.output;
964
+ if (resultRaw !== void 0 || status === "done" || status === "failed") {
965
+ if (resultRaw !== void 0) {
966
+ const r = serializeResult(resultRaw);
967
+ if (r) {
968
+ resultText = r.text;
969
+ if (r.truncated) truncated.result = true;
970
+ }
971
+ }
972
+ if (data.isError === true || status === "failed") isError = true;
973
+ else if (data.isError === false) isError = false;
974
+ }
975
+ const durationRaw = data.durationMs ?? data.duration;
976
+ const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
977
+ const hasTruncated = truncated.args === true || truncated.progress === true || truncated.result === true;
675
978
  return {
676
979
  kind: "tool",
677
980
  toolCallId,
678
981
  name,
679
982
  ...phase ? { phase } : {},
680
983
  status,
681
- ...summary ? { summary } : {}
984
+ ...summary ? { summary } : {},
985
+ ...argsText !== void 0 ? { argsText } : {},
986
+ ...progressText !== void 0 ? { progressText } : {},
987
+ ...resultText !== void 0 ? { resultText } : {},
988
+ ...isError !== void 0 ? { isError } : {},
989
+ ...durationMs !== void 0 ? { durationMs } : {},
990
+ ...hasTruncated ? { truncated } : {}
682
991
  };
683
992
  }
684
993
  /**
@@ -858,11 +1167,25 @@ async function handleAgentRequest(request, log) {
858
1167
  const sessionId = conversationId ?? legacySessionKey;
859
1168
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
860
1169
  await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
861
- let resolvedOpenClawKey = null;
1170
+ const runGate = {
1171
+ runId: null,
1172
+ sessionKey: null
1173
+ };
1174
+ const UPDATE_THROTTLE_MS = 250;
1175
+ const pendingUpdates = /* @__PURE__ */ new Map();
1176
+ const flushToolUpdate = (toolCallId) => {
1177
+ const pending = pendingUpdates.get(toolCallId);
1178
+ if (!pending) return;
1179
+ clearTimeout(pending.timer);
1180
+ pendingUpdates.delete(toolCallId);
1181
+ pending.send();
1182
+ };
1183
+ const clearAllToolUpdates = () => {
1184
+ for (const pending of pendingUpdates.values()) clearTimeout(pending.timer);
1185
+ pendingUpdates.clear();
1186
+ };
862
1187
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
863
- if (!evt.sessionKey) return;
864
- if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
865
- if (evt.sessionKey !== resolvedOpenClawKey) return;
1188
+ if (!admitAgentEvent(runGate, evt)) return;
866
1189
  if (evt.stream === "assistant") {
867
1190
  chatClient?.sendEvent("chat", {
868
1191
  runId: evt.runId,
@@ -890,17 +1213,38 @@ async function handleAgentRequest(request, log) {
890
1213
  }
891
1214
  if (evt.stream === "tool") {
892
1215
  const activity = buildToolActivity(evt.data);
893
- if (activity) chatClient?.sendEvent("chat-activity", {
894
- runId: evt.runId,
895
- sessionKey: legacySessionKey,
896
- seq: evt.seq,
897
- state: "activity",
898
- activity: {
899
- ...activity,
900
- ts: evt.ts,
901
- seq: evt.seq
1216
+ if (activity?.kind !== "tool") return;
1217
+ const toolCallId = activity.toolCallId;
1218
+ const send = () => {
1219
+ chatClient?.sendEvent("chat-activity", {
1220
+ runId: evt.runId,
1221
+ sessionKey: legacySessionKey,
1222
+ seq: evt.seq,
1223
+ state: "activity",
1224
+ activity: {
1225
+ ...activity,
1226
+ ts: evt.ts,
1227
+ seq: evt.seq
1228
+ }
1229
+ });
1230
+ };
1231
+ const rawPhase = (activity.phase ?? "").toLowerCase();
1232
+ if (rawPhase === "update" || activity.status === "running" && rawPhase !== "start" && rawPhase !== "") {
1233
+ const existing = pendingUpdates.get(toolCallId);
1234
+ if (existing) existing.send = send;
1235
+ else {
1236
+ const timer = setTimeout(() => {
1237
+ flushToolUpdate(toolCallId);
1238
+ }, UPDATE_THROTTLE_MS);
1239
+ pendingUpdates.set(toolCallId, {
1240
+ timer,
1241
+ send
1242
+ });
902
1243
  }
903
- });
1244
+ return;
1245
+ }
1246
+ flushToolUpdate(toolCallId);
1247
+ send();
904
1248
  return;
905
1249
  }
906
1250
  });
@@ -919,7 +1263,7 @@ async function handleAgentRequest(request, log) {
919
1263
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
920
1264
  let a2aResponseBuffer = "";
921
1265
  if (isA2A) resetA2AEndSignal();
922
- resolvedOpenClawKey = (await dispatchInbound({
1266
+ runGate.sessionKey = (await dispatchInbound({
923
1267
  cfg,
924
1268
  runtime: { channel: runtime.channel },
925
1269
  channel: "alfe",
@@ -972,7 +1316,7 @@ async function handleAgentRequest(request, log) {
972
1316
  chatClient?.notify("agent-message", {
973
1317
  conversationId: conversationId ?? legacySessionKey,
974
1318
  text: responseText,
975
- sessionKey: resolvedOpenClawKey ?? legacySessionKey,
1319
+ sessionKey: runGate.sessionKey ?? legacySessionKey,
976
1320
  messageId: chatMessageId ?? request.id,
977
1321
  ...mediaUrls.length ? { mediaUrls } : {}
978
1322
  });
@@ -993,14 +1337,15 @@ async function handleAgentRequest(request, log) {
993
1337
  resolved: a2aResolved
994
1338
  }));
995
1339
  }
996
- chatClient?.sendResponse(request.id, true, { sessionKey: resolvedOpenClawKey });
997
- log.info(`Agent dispatch complete: sessionKey=${resolvedOpenClawKey}`);
1340
+ chatClient?.sendResponse(request.id, true, { sessionKey: runGate.sessionKey });
1341
+ log.info(`Agent dispatch complete: sessionKey=${runGate.sessionKey}`);
998
1342
  } catch (err) {
999
1343
  const errMsg = err instanceof Error ? err.message : String(err);
1000
1344
  log.error(`Agent dispatch failed: ${errMsg}`);
1001
1345
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1002
1346
  } finally {
1003
1347
  unsubscribe();
1348
+ clearAllToolUpdates();
1004
1349
  if (isA2A) disarmA2AEndSignal();
1005
1350
  }
1006
1351
  }
@@ -1229,4 +1574,4 @@ const plugin = {
1229
1574
  }
1230
1575
  };
1231
1576
  //#endregion
1232
- export { createAlfeChannelPlugin as i, plugin as n, stripLeakedToolSummary as r, buildA2ACompletePayload as t };
1577
+ export { stripLeakedToolSummary as a, plugin as i, buildA2ACompletePayload as n, createAlfeChannelPlugin as o, buildToolActivity as r, admitAgentEvent as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",