@alfe.ai/openclaw-chat 0.9.4 → 0.9.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.
package/dist/index.d.cts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as ChatMessage, S as AlfeResolvedAccount, T as SessionSummary, b as AlfeChannelConfig, h as plugin, w as SessionData, x as AlfePluginConfig, y as createAlfeChannelPlugin } from "./plugin.cjs";
1
+ import { C as AlfeResolvedAccount, E as SessionSummary, S as AlfePluginConfig, T as SessionData, b as createAlfeChannelPlugin, g as plugin, w as ChatMessage, x as AlfeChannelConfig } from "./plugin.cjs";
2
2
  export { type AlfeChannelConfig, type AlfePluginConfig, type AlfeResolvedAccount, type ChatMessage, type SessionData, type SessionSummary, createAlfeChannelPlugin, plugin as default };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as ChatMessage, S as AlfeResolvedAccount, T as SessionSummary, b as AlfeChannelConfig, h as plugin, w as SessionData, x as AlfePluginConfig, y as createAlfeChannelPlugin } from "./plugin.js";
1
+ import { C as AlfeResolvedAccount, E as SessionSummary, S as AlfePluginConfig, T as SessionData, b as createAlfeChannelPlugin, g as plugin, w as ChatMessage, x as AlfeChannelConfig } from "./plugin.js";
2
2
  export { type AlfeChannelConfig, type AlfePluginConfig, type AlfeResolvedAccount, type ChatMessage, type SessionData, type SessionSummary, createAlfeChannelPlugin, plugin as default };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { f as plugin, g as createAlfeChannelPlugin } from "./plugin2.js";
1
+ import { _ as createAlfeChannelPlugin, p as plugin } from "./plugin2.js";
2
2
  export { createAlfeChannelPlugin, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -13,6 +13,7 @@ exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
13
13
  exports.buildOriginEnvelopeContext = require_plugin.buildOriginEnvelopeContext;
14
14
  exports.buildToolActivity = require_plugin.buildToolActivity;
15
15
  exports.computeOpenClawSdkAnchors = require_plugin.computeOpenClawSdkAnchors;
16
+ exports.decideChatServiceStart = require_plugin.decideChatServiceStart;
16
17
  exports.default = require_plugin.plugin;
17
18
  exports.joinAssistantText = require_plugin.joinAssistantText;
18
19
  exports.resolveAbortTargetKeys = require_plugin.resolveAbortTargetKeys;
package/dist/plugin.d.cts CHANGED
@@ -64,6 +64,20 @@ type StoredMessageComponent = {
64
64
  label: string;
65
65
  value: string;
66
66
  };
67
+ /**
68
+ * Persisted reference to a message attachment. Deliberately stores NO URL —
69
+ * the download URL is short-lived and re-signed on demand through the chat
70
+ * service's participant-gated resolve endpoint (keyed by `attachmentId`). Only
71
+ * media bound to the conversation (a real chat-bucket upload) is persisted;
72
+ * remote-passthrough media has no `attachmentId` and is not stored.
73
+ */
74
+ interface StoredAttachment {
75
+ attachmentId: string;
76
+ type: 'image' | 'video' | 'audio' | 'document' | 'file';
77
+ mimeType: string;
78
+ filename: string;
79
+ size?: number;
80
+ }
67
81
  interface ChatMessage {
68
82
  role: 'user' | 'assistant';
69
83
  content: string;
@@ -77,6 +91,12 @@ interface ChatMessage {
77
91
  * content). Persisted so a refresh / reconnect replays the button.
78
92
  */
79
93
  components?: StoredMessageComponent[];
94
+ /**
95
+ * Attachment references (images/files). Additive — absent on pre-attachment
96
+ * files. Persisted so history replays them; the client resolves each URL on
97
+ * demand via the participant-gated endpoint. See {@link StoredAttachment}.
98
+ */
99
+ attachments?: StoredAttachment[];
80
100
  }
81
101
  /**
82
102
  * Persisted form of a turn's agent activity (tool cards + thinking), so a
@@ -579,6 +599,15 @@ interface AnchorEnv {
579
599
  requireMainFilename: string | undefined;
580
600
  argv1: string | undefined;
581
601
  execPath: string;
602
+ /**
603
+ * Well-known global `node_modules` directories to try as `<dir>/openclaw`
604
+ * anchors, independent of PATH / `which` / execPath. Load-bearing when the
605
+ * plugin runs in-process inside a runtime whose PATH is stripped or whose
606
+ * `child_process` is sandboxed (homebrew-keg self-hosted macOS), so every
607
+ * environment-derived anchor above returns nothing. Optional — defaults to
608
+ * none (tests that don't exercise this pass `undefined`).
609
+ */
610
+ globalModulesDirs?: string[];
582
611
  }
583
612
  /**
584
613
  * Compute the ordered, de-duplicated list of resolver anchors to try, each
@@ -600,6 +629,32 @@ interface AnchorEnv {
600
629
  * Pure except for the injected `realpath` / `which` seams — unit-tested.
601
630
  */
602
631
  declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
632
+ /**
633
+ * Decide what `startChatService()` should do given the current connection
634
+ * state. Pure so the branch logic — the load-bearing fix for the
635
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
636
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
637
+ *
638
+ * OpenClaw calls the registered service's `start()` in two very different
639
+ * situations that a sticky activation flag could not tell apart:
640
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
641
+ * → must be a no-op ('skip'), and
642
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
643
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
644
+ * with no preceding `stop()` → must actually reconnect.
645
+ *
646
+ * Keying on the live client fixes it:
647
+ * - a connect in flight, or an already-connected client → 'skip'
648
+ * - a client that exists but is not connected → 'recycle' it then reconnect
649
+ * - no client at all → 'connect'
650
+ *
651
+ * Exported for unit tests.
652
+ */
653
+ declare function decideChatServiceStart(state: {
654
+ connectInFlight: boolean;
655
+ clientPresent: boolean;
656
+ clientConnected: boolean;
657
+ }): 'skip' | 'recycle' | 'connect';
603
658
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
604
659
  coalesceKey?: string;
605
660
  onSuperseded?: () => void;
@@ -681,4 +736,4 @@ declare const plugin: {
681
736
  deactivate(api: PluginApi): Promise<void>;
682
737
  };
683
738
  //#endregion
684
- export { ChatMessage as C, AlfeResolvedAccount as S, SessionSummary as T, rewriteAssistantText as _, VOICE_REPLY_SYSTEM_PROMPT as a, AlfeChannelConfig as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, resolveAbortTargetKeys as g, plugin as h, RunEventGate as i, admitAgentEvent as l, joinAssistantText as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, stripLeakedToolSummary as v, SessionData as w, AlfePluginConfig as x, createAlfeChannelPlugin as y };
739
+ export { AlfeResolvedAccount as C, SessionSummary as E, AlfePluginConfig as S, SessionData as T, resolveAbortTargetKeys as _, VOICE_REPLY_SYSTEM_PROMPT as a, createAlfeChannelPlugin as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, plugin as g, joinAssistantText as h, RunEventGate as i, admitAgentEvent as l, decideChatServiceStart as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, rewriteAssistantText as v, ChatMessage as w, AlfeChannelConfig as x, stripLeakedToolSummary as y };
package/dist/plugin.d.ts CHANGED
@@ -64,6 +64,20 @@ type StoredMessageComponent = {
64
64
  label: string;
65
65
  value: string;
66
66
  };
67
+ /**
68
+ * Persisted reference to a message attachment. Deliberately stores NO URL —
69
+ * the download URL is short-lived and re-signed on demand through the chat
70
+ * service's participant-gated resolve endpoint (keyed by `attachmentId`). Only
71
+ * media bound to the conversation (a real chat-bucket upload) is persisted;
72
+ * remote-passthrough media has no `attachmentId` and is not stored.
73
+ */
74
+ interface StoredAttachment {
75
+ attachmentId: string;
76
+ type: 'image' | 'video' | 'audio' | 'document' | 'file';
77
+ mimeType: string;
78
+ filename: string;
79
+ size?: number;
80
+ }
67
81
  interface ChatMessage {
68
82
  role: 'user' | 'assistant';
69
83
  content: string;
@@ -77,6 +91,12 @@ interface ChatMessage {
77
91
  * content). Persisted so a refresh / reconnect replays the button.
78
92
  */
79
93
  components?: StoredMessageComponent[];
94
+ /**
95
+ * Attachment references (images/files). Additive — absent on pre-attachment
96
+ * files. Persisted so history replays them; the client resolves each URL on
97
+ * demand via the participant-gated endpoint. See {@link StoredAttachment}.
98
+ */
99
+ attachments?: StoredAttachment[];
80
100
  }
81
101
  /**
82
102
  * Persisted form of a turn's agent activity (tool cards + thinking), so a
@@ -579,6 +599,15 @@ interface AnchorEnv {
579
599
  requireMainFilename: string | undefined;
580
600
  argv1: string | undefined;
581
601
  execPath: string;
602
+ /**
603
+ * Well-known global `node_modules` directories to try as `<dir>/openclaw`
604
+ * anchors, independent of PATH / `which` / execPath. Load-bearing when the
605
+ * plugin runs in-process inside a runtime whose PATH is stripped or whose
606
+ * `child_process` is sandboxed (homebrew-keg self-hosted macOS), so every
607
+ * environment-derived anchor above returns nothing. Optional — defaults to
608
+ * none (tests that don't exercise this pass `undefined`).
609
+ */
610
+ globalModulesDirs?: string[];
582
611
  }
583
612
  /**
584
613
  * Compute the ordered, de-duplicated list of resolver anchors to try, each
@@ -600,6 +629,32 @@ interface AnchorEnv {
600
629
  * Pure except for the injected `realpath` / `which` seams — unit-tested.
601
630
  */
602
631
  declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
632
+ /**
633
+ * Decide what `startChatService()` should do given the current connection
634
+ * state. Pure so the branch logic — the load-bearing fix for the
635
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
636
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
637
+ *
638
+ * OpenClaw calls the registered service's `start()` in two very different
639
+ * situations that a sticky activation flag could not tell apart:
640
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
641
+ * → must be a no-op ('skip'), and
642
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
643
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
644
+ * with no preceding `stop()` → must actually reconnect.
645
+ *
646
+ * Keying on the live client fixes it:
647
+ * - a connect in flight, or an already-connected client → 'skip'
648
+ * - a client that exists but is not connected → 'recycle' it then reconnect
649
+ * - no client at all → 'connect'
650
+ *
651
+ * Exported for unit tests.
652
+ */
653
+ declare function decideChatServiceStart(state: {
654
+ connectInFlight: boolean;
655
+ clientPresent: boolean;
656
+ clientConnected: boolean;
657
+ }): 'skip' | 'recycle' | 'connect';
603
658
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
604
659
  coalesceKey?: string;
605
660
  onSuperseded?: () => void;
@@ -681,4 +736,4 @@ declare const plugin: {
681
736
  deactivate(api: PluginApi): Promise<void>;
682
737
  };
683
738
  //#endregion
684
- export { ChatMessage as C, AlfeResolvedAccount as S, SessionSummary as T, rewriteAssistantText as _, VOICE_REPLY_SYSTEM_PROMPT as a, AlfeChannelConfig as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, resolveAbortTargetKeys as g, plugin as h, RunEventGate as i, admitAgentEvent as l, joinAssistantText as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, stripLeakedToolSummary as v, SessionData as w, AlfePluginConfig as x, createAlfeChannelPlugin as y };
739
+ export { AlfeResolvedAccount as C, SessionSummary as E, AlfePluginConfig as S, SessionData as T, resolveAbortTargetKeys as _, VOICE_REPLY_SYSTEM_PROMPT as a, createAlfeChannelPlugin as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, plugin as g, joinAssistantText as h, RunEventGate as i, admitAgentEvent as l, decideChatServiceStart as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, rewriteAssistantText as v, ChatMessage as w, AlfeChannelConfig as x, stripLeakedToolSummary as y };
package/dist/plugin.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as __setActiveRunForTest, c as buildOriginEnvelopeContext, d as joinAssistantText, f as plugin, h as stripLeakedToolSummary, i as __resetAgentTurnQueueForTest, l as buildToolActivity, m as rewriteAssistantText, n as VOICE_REPLY_SYSTEM_PROMPT, o as admitAgentEvent, p as resolveAbortTargetKeys, r as __agentTurnQueueForTest, s as buildA2ACompletePayload, t as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, u as computeOpenClawSdkAnchors } from "./plugin2.js";
2
- export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { a as __setActiveRunForTest, c as buildOriginEnvelopeContext, d as decideChatServiceStart, f as joinAssistantText, g as stripLeakedToolSummary, h as rewriteAssistantText, i as __resetAgentTurnQueueForTest, l as buildToolActivity, m as resolveAbortTargetKeys, n as VOICE_REPLY_SYSTEM_PROMPT, o as admitAgentEvent, p as plugin, r as __agentTurnQueueForTest, s as buildA2ACompletePayload, t as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, u as computeOpenClawSdkAnchors } from "./plugin2.js";
2
+ export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.cjs CHANGED
@@ -3,6 +3,7 @@ let node_fs_promises = require("node:fs/promises");
3
3
  let node_fs = require("node:fs");
4
4
  let node_child_process = require("node:child_process");
5
5
  let node_path = require("node:path");
6
+ let node_url = require("node:url");
6
7
  let node_os = require("node:os");
7
8
  let _alfe_ai_chat = require("@alfe.ai/chat");
8
9
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
@@ -210,7 +211,8 @@ async function uploadLocalFile(localPath, log, deps) {
210
211
  url: presigned.downloadUrl,
211
212
  filename,
212
213
  mimeType,
213
- size
214
+ size,
215
+ attachmentId: presigned.id
214
216
  };
215
217
  } catch (err) {
216
218
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -874,7 +876,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
874
876
  return session;
875
877
  }
876
878
  const writeLocks = /* @__PURE__ */ new Map();
877
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
879
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
878
880
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
879
881
  const session = await getSession(sessionId);
880
882
  if (!session) return;
@@ -884,7 +886,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
884
886
  timestamp: Date.now(),
885
887
  ...senderId ? { senderId } : {},
886
888
  ...senderName ? { senderName } : {},
887
- ...components?.length ? { components } : {}
889
+ ...components?.length ? { components } : {},
890
+ ...attachments?.length ? { attachments } : {}
888
891
  });
889
892
  await saveSession(session);
890
893
  }).finally(() => {
@@ -2070,24 +2073,33 @@ let resolveRouteEnvelope = null;
2070
2073
  * degrades `chat.abort` to a logged no-op (Stop still seals the UI) rather than
2071
2074
  * a hard failure.
2072
2075
  */
2073
- function loadOpenClawSdk(req, log, anchor) {
2074
- let resolvedDispatch = false;
2075
- try {
2076
- const channelInbound = req("openclaw/plugin-sdk/channel-inbound");
2077
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
2078
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
2079
- resolvedDispatch = true;
2076
+ async function loadOpenClawSdk(req, log, anchor) {
2077
+ const loadSubpath = async (subpath) => {
2078
+ try {
2079
+ const mod = await import((0, node_url.pathToFileURL)(req.resolve(subpath)).href);
2080
+ return {
2081
+ ...typeof mod.default === "object" ? mod.default : void 0,
2082
+ ...mod
2083
+ };
2084
+ } catch {}
2085
+ try {
2086
+ return req(subpath);
2087
+ } catch {
2088
+ return;
2080
2089
  }
2081
- } catch {}
2082
- try {
2083
- const harness = req("openclaw/plugin-sdk/agent-harness");
2084
- if (harness.abortEmbeddedAgentRun) abortEmbeddedAgentRun = harness.abortEmbeddedAgentRun;
2085
- if (harness.resolveActiveEmbeddedRunSessionId) resolveActiveRunSessionId = harness.resolveActiveEmbeddedRunSessionId;
2086
- } catch {}
2087
- try {
2088
- const envelope = req("openclaw/plugin-sdk/inbound-envelope");
2089
- if (envelope.resolveInboundRouteEnvelopeBuilderWithRuntime) resolveRouteEnvelope = envelope.resolveInboundRouteEnvelopeBuilderWithRuntime;
2090
- } catch {}
2090
+ };
2091
+ let resolvedDispatch = false;
2092
+ const channelInbound = await loadSubpath("openclaw/plugin-sdk/channel-inbound");
2093
+ if (channelInbound?.dispatchInboundDirectDmWithRuntime) {
2094
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
2095
+ resolvedDispatch = true;
2096
+ }
2097
+ const harness = await loadSubpath("openclaw/plugin-sdk/agent-harness");
2098
+ const abortRun = harness?.abortAgentHarnessRun ?? harness?.abortEmbeddedAgentRun;
2099
+ if (abortRun) abortEmbeddedAgentRun = abortRun;
2100
+ if (harness?.resolveActiveEmbeddedRunSessionId) resolveActiveRunSessionId = harness.resolveActiveEmbeddedRunSessionId;
2101
+ const envelope = await loadSubpath("openclaw/plugin-sdk/inbound-envelope");
2102
+ if (envelope?.resolveInboundRouteEnvelopeBuilderWithRuntime) resolveRouteEnvelope = envelope.resolveInboundRouteEnvelopeBuilderWithRuntime;
2091
2103
  if (resolvedDispatch) log.info(`Resolved OpenClaw SDK from ${anchor} (hard-abort=${abortEmbeddedAgentRun && resolveActiveRunSessionId ? "available" : "unavailable"})`);
2092
2104
  return resolvedDispatch;
2093
2105
  }
@@ -2158,6 +2170,7 @@ function computeOpenClawSdkAnchors(deps) {
2158
2170
  try {
2159
2171
  push(deps.which("openclaw"));
2160
2172
  } catch {}
2173
+ for (const dir of deps.globalModulesDirs ?? []) push((0, node_path.join)(dir, "openclaw", "package.json"));
2161
2174
  for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
2162
2175
  if (!base) continue;
2163
2176
  try {
@@ -2176,6 +2189,27 @@ function safeRealpath(realpath, p) {
2176
2189
  }
2177
2190
  }
2178
2191
  /**
2192
+ * Well-known global `node_modules` locations to try as SDK anchors, in priority
2193
+ * order. Environment-INDEPENDENT (no PATH / `which` / execPath), so they resolve
2194
+ * the SDK even when the in-process plugin context has a stripped PATH or a
2195
+ * sandboxed `child_process` (the homebrew-keg self-hosted macOS case where every
2196
+ * environment-derived anchor returns nothing). Non-existent candidates are
2197
+ * harmless — `createRequire` just fails and the next anchor runs. `NODE_PATH`,
2198
+ * when set, is an explicit module-dir list, so honour it too.
2199
+ */
2200
+ function candidateGlobalModulesDirs() {
2201
+ const dirs = [
2202
+ "/opt/homebrew/lib/node_modules",
2203
+ "/usr/local/lib/node_modules",
2204
+ "/usr/lib/node_modules",
2205
+ "/usr/local/share/npm/lib/node_modules"
2206
+ ];
2207
+ const home = process.env.HOME;
2208
+ if (home) dirs.push((0, node_path.join)(home, ".npm-global", "lib", "node_modules"));
2209
+ for (const d of (process.env.NODE_PATH ?? "").split(node_path.delimiter)) if (d) dirs.push(d);
2210
+ return dirs;
2211
+ }
2212
+ /**
2179
2213
  * Resolve OpenClaw SDK functions from the running process.
2180
2214
  *
2181
2215
  * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
@@ -2184,23 +2218,123 @@ function safeRealpath(realpath, p) {
2184
2218
  * relocated-node layouts), then to a `which openclaw` lookup, then fall back to
2185
2219
  * deriving the global modules path from process.execPath.
2186
2220
  */
2187
- function resolveOpenClawSdk(log) {
2221
+ async function resolveOpenClawSdk(log) {
2188
2222
  const pathEnv = process.env.PATH;
2189
2223
  const anchors = computeOpenClawSdkAnchors({
2190
2224
  realpath: node_fs.realpathSync,
2191
2225
  which: () => whichOpenClaw(pathEnv),
2192
2226
  requireMainFilename: require$1.main?.filename,
2193
2227
  argv1: process.argv[1],
2194
- execPath: process.execPath
2228
+ execPath: process.execPath,
2229
+ globalModulesDirs: candidateGlobalModulesDirs()
2195
2230
  });
2196
2231
  for (const anchor of anchors) try {
2197
- if (loadOpenClawSdk((0, node_module.createRequire)(anchor), log, anchor)) return;
2232
+ if (await loadOpenClawSdk((0, node_module.createRequire)(anchor), log, anchor)) return;
2198
2233
  } catch {}
2199
- log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
2234
+ log.warn(`OpenClaw SDK not resolvable — chat dispatch will not work. argv1=${process.argv[1] ?? "<none>"} execPath=${process.execPath} PATH=${pathEnv ? "set" : "EMPTY"} anchorsTried=[${anchors.join(", ")}]`);
2200
2235
  }
2201
2236
  let pluginRuntime = null;
2202
2237
  let chatClient = null;
2203
2238
  let connectingPromise = null;
2239
+ /**
2240
+ * Decide what `startChatService()` should do given the current connection
2241
+ * state. Pure so the branch logic — the load-bearing fix for the
2242
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2243
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2244
+ *
2245
+ * OpenClaw calls the registered service's `start()` in two very different
2246
+ * situations that a sticky activation flag could not tell apart:
2247
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2248
+ * → must be a no-op ('skip'), and
2249
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2250
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2251
+ * with no preceding `stop()` → must actually reconnect.
2252
+ *
2253
+ * Keying on the live client fixes it:
2254
+ * - a connect in flight, or an already-connected client → 'skip'
2255
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2256
+ * - no client at all → 'connect'
2257
+ *
2258
+ * Exported for unit tests.
2259
+ */
2260
+ function decideChatServiceStart(state) {
2261
+ if (state.connectInFlight) return "skip";
2262
+ if (state.clientPresent && state.clientConnected) return "skip";
2263
+ if (state.clientPresent) return "recycle";
2264
+ return "connect";
2265
+ }
2266
+ const STORED_ATTACHMENT_TYPES = new Set([
2267
+ "image",
2268
+ "video",
2269
+ "audio",
2270
+ "document",
2271
+ "file"
2272
+ ]);
2273
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2274
+ function coerceStoredAttachmentType(type, mimeType) {
2275
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2276
+ const m = mimeType ?? "";
2277
+ if (m.startsWith("image/")) return "image";
2278
+ if (m.startsWith("video/")) return "video";
2279
+ if (m.startsWith("audio/")) return "audio";
2280
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2281
+ return "file";
2282
+ }
2283
+ /**
2284
+ * Map inbound user attachments to the persisted reference shape (drops the
2285
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2286
+ * chat-bucket `id`, so all are persisted.
2287
+ */
2288
+ function userAttachmentsToStored(refs) {
2289
+ if (!refs?.length) return void 0;
2290
+ const out = [];
2291
+ for (const r of refs) {
2292
+ if (!r.id) continue;
2293
+ out.push({
2294
+ attachmentId: r.id,
2295
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2296
+ mimeType: r.mimeType,
2297
+ filename: r.filename ?? "file",
2298
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2299
+ });
2300
+ }
2301
+ return out.length ? out : void 0;
2302
+ }
2303
+ /**
2304
+ * Map resolved assistant media to persisted references. Only media uploaded to
2305
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2306
+ * has no bindable record and stays live-URL-only.
2307
+ */
2308
+ function assistantMediaToStored(media) {
2309
+ if (!media?.length) return void 0;
2310
+ const out = [];
2311
+ for (const m of media) {
2312
+ if (!m.attachmentId) continue;
2313
+ out.push({
2314
+ attachmentId: m.attachmentId,
2315
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2316
+ mimeType: m.mimeType ?? "application/octet-stream",
2317
+ filename: m.filename ?? "file",
2318
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2319
+ });
2320
+ }
2321
+ return out.length ? out : void 0;
2322
+ }
2323
+ /**
2324
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2325
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2326
+ * client resolves a short-lived signed URL on demand.
2327
+ */
2328
+ function replayAttachments(atts) {
2329
+ if (!atts?.length) return void 0;
2330
+ return atts.map((a) => ({
2331
+ id: a.attachmentId,
2332
+ type: a.type,
2333
+ mimeType: a.mimeType,
2334
+ filename: a.filename,
2335
+ ...typeof a.size === "number" ? { size: a.size } : {}
2336
+ }));
2337
+ }
2204
2338
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2205
2339
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2206
2340
  const MAX_REDIRECTS = 5;
@@ -2450,7 +2584,7 @@ async function handleAgentRequest(request, log) {
2450
2584
  const cfg = runtime.config.loadConfig();
2451
2585
  const sessionId = conversationId ?? legacySessionKey;
2452
2586
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2453
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2587
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2454
2588
  const runGate = {
2455
2589
  runId: null,
2456
2590
  sessionKey: null
@@ -2686,7 +2820,7 @@ async function handleAgentRequest(request, log) {
2686
2820
  const note = formatMediaFailureNote(media.newFailures);
2687
2821
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2688
2822
  }
2689
- await addMessage(sessionId, "assistant", responseText);
2823
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2690
2824
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2691
2825
  chatClient?.notify("agent-message", {
2692
2826
  conversationId: conversationId ?? legacySessionKey,
@@ -2799,7 +2933,8 @@ async function handleSessionsGet(request, log) {
2799
2933
  role: m.role,
2800
2934
  content: m.content,
2801
2935
  timestamp: m.timestamp,
2802
- ...m.components?.length ? { components: m.components } : {}
2936
+ ...m.components?.length ? { components: m.components } : {},
2937
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2803
2938
  })),
2804
2939
  activity: await resolveHistoryActivity(session)
2805
2940
  });
@@ -2843,16 +2978,31 @@ const plugin = {
2843
2978
  log.info(`Registered ${String(a2aTools.length)} agent tools`);
2844
2979
  }
2845
2980
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2846
- const startChatService = () => {
2847
- if (globalThis.__alfeChatPluginActivated === true) {
2848
- log.debug("Chat plugin already activated — skipping duplicate");
2981
+ const startChatService = async () => {
2982
+ const action = decideChatServiceStart({
2983
+ connectInFlight: connectingPromise !== null,
2984
+ clientPresent: chatClient !== null,
2985
+ clientConnected: chatClient?.isConnected ?? false
2986
+ });
2987
+ if (action === "skip") {
2988
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2849
2989
  return;
2850
2990
  }
2991
+ if (action === "recycle") {
2992
+ log.info("Chat service client present but not connected — recycling before reconnect");
2993
+ try {
2994
+ chatClient?.stop();
2995
+ } catch (err) {
2996
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2997
+ }
2998
+ chatClient = null;
2999
+ }
2851
3000
  globalThis.__alfeChatPluginActivated = true;
2852
3001
  log.info("Chat plugin registering...");
2853
- resolveOpenClawSdk(log);
3002
+ await resolveOpenClawSdk(log);
2854
3003
  pluginRuntime = api.runtime ?? null;
2855
- connectingPromise = Promise.resolve().then(() => {
3004
+ let thisConnect = null;
3005
+ thisConnect = Promise.resolve().then(() => {
2856
3006
  try {
2857
3007
  const { apiKey, chatWsUrl } = (0, _alfe_ai_chat.resolveAlfeChat)({
2858
3008
  apiKey: pluginConfig.apiKey,
@@ -2900,7 +3050,10 @@ const plugin = {
2900
3050
  } catch (err) {
2901
3051
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2902
3052
  }
3053
+ }).finally(() => {
3054
+ if (connectingPromise === thisConnect) connectingPromise = null;
2903
3055
  });
3056
+ connectingPromise = thisConnect;
2904
3057
  };
2905
3058
  const stopChatService = async () => {
2906
3059
  globalThis.__alfeChatPluginActivated = false;
@@ -2952,7 +3105,8 @@ const plugin = {
2952
3105
  role: m.role,
2953
3106
  content: m.content,
2954
3107
  timestamp: m.timestamp,
2955
- ...m.components?.length ? { components: m.components } : {}
3108
+ ...m.components?.length ? { components: m.components } : {},
3109
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2956
3110
  })),
2957
3111
  activity: await resolveHistoryActivity(session)
2958
3112
  };
@@ -3079,6 +3233,12 @@ Object.defineProperty(exports, "createAlfeChannelPlugin", {
3079
3233
  return createAlfeChannelPlugin;
3080
3234
  }
3081
3235
  });
3236
+ Object.defineProperty(exports, "decideChatServiceStart", {
3237
+ enumerable: true,
3238
+ get: function() {
3239
+ return decideChatServiceStart;
3240
+ }
3241
+ });
3082
3242
  Object.defineProperty(exports, "joinAssistantText", {
3083
3243
  enumerable: true,
3084
3244
  get: function() {
@@ -1,2 +1,2 @@
1
- import { _ as rewriteAssistantText, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as resolveAbortTargetKeys, h as plugin, i as RunEventGate, l as admitAgentEvent, m as joinAssistantText, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as stripLeakedToolSummary } from "./plugin.cjs";
2
- export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { _ as resolveAbortTargetKeys, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as plugin, h as joinAssistantText, i as RunEventGate, l as admitAgentEvent, m as decideChatServiceStart, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as rewriteAssistantText, y as stripLeakedToolSummary } from "./plugin.cjs";
2
+ export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as rewriteAssistantText, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as resolveAbortTargetKeys, h as plugin, i as RunEventGate, l as admitAgentEvent, m as joinAssistantText, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as stripLeakedToolSummary } from "./plugin.js";
2
- export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { _ as resolveAbortTargetKeys, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as plugin, h as joinAssistantText, i as RunEventGate, l as admitAgentEvent, m as decideChatServiceStart, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as rewriteAssistantText, y as stripLeakedToolSummary } from "./plugin.js";
2
+ export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.js CHANGED
@@ -3,6 +3,7 @@ import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs
3
3
  import { existsSync, realpathSync } from "node:fs";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
6
7
  import { homedir } from "node:os";
7
8
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
8
9
  import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
@@ -210,7 +211,8 @@ async function uploadLocalFile(localPath, log, deps) {
210
211
  url: presigned.downloadUrl,
211
212
  filename,
212
213
  mimeType,
213
- size
214
+ size,
215
+ attachmentId: presigned.id
214
216
  };
215
217
  } catch (err) {
216
218
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -874,7 +876,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
874
876
  return session;
875
877
  }
876
878
  const writeLocks = /* @__PURE__ */ new Map();
877
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
879
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
878
880
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
879
881
  const session = await getSession(sessionId);
880
882
  if (!session) return;
@@ -884,7 +886,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
884
886
  timestamp: Date.now(),
885
887
  ...senderId ? { senderId } : {},
886
888
  ...senderName ? { senderName } : {},
887
- ...components?.length ? { components } : {}
889
+ ...components?.length ? { components } : {},
890
+ ...attachments?.length ? { attachments } : {}
888
891
  });
889
892
  await saveSession(session);
890
893
  }).finally(() => {
@@ -2070,24 +2073,33 @@ let resolveRouteEnvelope = null;
2070
2073
  * degrades `chat.abort` to a logged no-op (Stop still seals the UI) rather than
2071
2074
  * a hard failure.
2072
2075
  */
2073
- function loadOpenClawSdk(req, log, anchor) {
2074
- let resolvedDispatch = false;
2075
- try {
2076
- const channelInbound = req("openclaw/plugin-sdk/channel-inbound");
2077
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
2078
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
2079
- resolvedDispatch = true;
2076
+ async function loadOpenClawSdk(req, log, anchor) {
2077
+ const loadSubpath = async (subpath) => {
2078
+ try {
2079
+ const mod = await import(pathToFileURL(req.resolve(subpath)).href);
2080
+ return {
2081
+ ...typeof mod.default === "object" ? mod.default : void 0,
2082
+ ...mod
2083
+ };
2084
+ } catch {}
2085
+ try {
2086
+ return req(subpath);
2087
+ } catch {
2088
+ return;
2080
2089
  }
2081
- } catch {}
2082
- try {
2083
- const harness = req("openclaw/plugin-sdk/agent-harness");
2084
- if (harness.abortEmbeddedAgentRun) abortEmbeddedAgentRun = harness.abortEmbeddedAgentRun;
2085
- if (harness.resolveActiveEmbeddedRunSessionId) resolveActiveRunSessionId = harness.resolveActiveEmbeddedRunSessionId;
2086
- } catch {}
2087
- try {
2088
- const envelope = req("openclaw/plugin-sdk/inbound-envelope");
2089
- if (envelope.resolveInboundRouteEnvelopeBuilderWithRuntime) resolveRouteEnvelope = envelope.resolveInboundRouteEnvelopeBuilderWithRuntime;
2090
- } catch {}
2090
+ };
2091
+ let resolvedDispatch = false;
2092
+ const channelInbound = await loadSubpath("openclaw/plugin-sdk/channel-inbound");
2093
+ if (channelInbound?.dispatchInboundDirectDmWithRuntime) {
2094
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
2095
+ resolvedDispatch = true;
2096
+ }
2097
+ const harness = await loadSubpath("openclaw/plugin-sdk/agent-harness");
2098
+ const abortRun = harness?.abortAgentHarnessRun ?? harness?.abortEmbeddedAgentRun;
2099
+ if (abortRun) abortEmbeddedAgentRun = abortRun;
2100
+ if (harness?.resolveActiveEmbeddedRunSessionId) resolveActiveRunSessionId = harness.resolveActiveEmbeddedRunSessionId;
2101
+ const envelope = await loadSubpath("openclaw/plugin-sdk/inbound-envelope");
2102
+ if (envelope?.resolveInboundRouteEnvelopeBuilderWithRuntime) resolveRouteEnvelope = envelope.resolveInboundRouteEnvelopeBuilderWithRuntime;
2091
2103
  if (resolvedDispatch) log.info(`Resolved OpenClaw SDK from ${anchor} (hard-abort=${abortEmbeddedAgentRun && resolveActiveRunSessionId ? "available" : "unavailable"})`);
2092
2104
  return resolvedDispatch;
2093
2105
  }
@@ -2158,6 +2170,7 @@ function computeOpenClawSdkAnchors(deps) {
2158
2170
  try {
2159
2171
  push(deps.which("openclaw"));
2160
2172
  } catch {}
2173
+ for (const dir of deps.globalModulesDirs ?? []) push(join(dir, "openclaw", "package.json"));
2161
2174
  for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
2162
2175
  if (!base) continue;
2163
2176
  try {
@@ -2176,6 +2189,27 @@ function safeRealpath(realpath, p) {
2176
2189
  }
2177
2190
  }
2178
2191
  /**
2192
+ * Well-known global `node_modules` locations to try as SDK anchors, in priority
2193
+ * order. Environment-INDEPENDENT (no PATH / `which` / execPath), so they resolve
2194
+ * the SDK even when the in-process plugin context has a stripped PATH or a
2195
+ * sandboxed `child_process` (the homebrew-keg self-hosted macOS case where every
2196
+ * environment-derived anchor returns nothing). Non-existent candidates are
2197
+ * harmless — `createRequire` just fails and the next anchor runs. `NODE_PATH`,
2198
+ * when set, is an explicit module-dir list, so honour it too.
2199
+ */
2200
+ function candidateGlobalModulesDirs() {
2201
+ const dirs = [
2202
+ "/opt/homebrew/lib/node_modules",
2203
+ "/usr/local/lib/node_modules",
2204
+ "/usr/lib/node_modules",
2205
+ "/usr/local/share/npm/lib/node_modules"
2206
+ ];
2207
+ const home = process.env.HOME;
2208
+ if (home) dirs.push(join(home, ".npm-global", "lib", "node_modules"));
2209
+ for (const d of (process.env.NODE_PATH ?? "").split(delimiter)) if (d) dirs.push(d);
2210
+ return dirs;
2211
+ }
2212
+ /**
2179
2213
  * Resolve OpenClaw SDK functions from the running process.
2180
2214
  *
2181
2215
  * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
@@ -2184,23 +2218,123 @@ function safeRealpath(realpath, p) {
2184
2218
  * relocated-node layouts), then to a `which openclaw` lookup, then fall back to
2185
2219
  * deriving the global modules path from process.execPath.
2186
2220
  */
2187
- function resolveOpenClawSdk(log) {
2221
+ async function resolveOpenClawSdk(log) {
2188
2222
  const pathEnv = process.env.PATH;
2189
2223
  const anchors = computeOpenClawSdkAnchors({
2190
2224
  realpath: realpathSync,
2191
2225
  which: () => whichOpenClaw(pathEnv),
2192
2226
  requireMainFilename: require.main?.filename,
2193
2227
  argv1: process.argv[1],
2194
- execPath: process.execPath
2228
+ execPath: process.execPath,
2229
+ globalModulesDirs: candidateGlobalModulesDirs()
2195
2230
  });
2196
2231
  for (const anchor of anchors) try {
2197
- if (loadOpenClawSdk(createRequire(anchor), log, anchor)) return;
2232
+ if (await loadOpenClawSdk(createRequire(anchor), log, anchor)) return;
2198
2233
  } catch {}
2199
- log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
2234
+ log.warn(`OpenClaw SDK not resolvable — chat dispatch will not work. argv1=${process.argv[1] ?? "<none>"} execPath=${process.execPath} PATH=${pathEnv ? "set" : "EMPTY"} anchorsTried=[${anchors.join(", ")}]`);
2200
2235
  }
2201
2236
  let pluginRuntime = null;
2202
2237
  let chatClient = null;
2203
2238
  let connectingPromise = null;
2239
+ /**
2240
+ * Decide what `startChatService()` should do given the current connection
2241
+ * state. Pure so the branch logic — the load-bearing fix for the
2242
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2243
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2244
+ *
2245
+ * OpenClaw calls the registered service's `start()` in two very different
2246
+ * situations that a sticky activation flag could not tell apart:
2247
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2248
+ * → must be a no-op ('skip'), and
2249
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2250
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2251
+ * with no preceding `stop()` → must actually reconnect.
2252
+ *
2253
+ * Keying on the live client fixes it:
2254
+ * - a connect in flight, or an already-connected client → 'skip'
2255
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2256
+ * - no client at all → 'connect'
2257
+ *
2258
+ * Exported for unit tests.
2259
+ */
2260
+ function decideChatServiceStart(state) {
2261
+ if (state.connectInFlight) return "skip";
2262
+ if (state.clientPresent && state.clientConnected) return "skip";
2263
+ if (state.clientPresent) return "recycle";
2264
+ return "connect";
2265
+ }
2266
+ const STORED_ATTACHMENT_TYPES = new Set([
2267
+ "image",
2268
+ "video",
2269
+ "audio",
2270
+ "document",
2271
+ "file"
2272
+ ]);
2273
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2274
+ function coerceStoredAttachmentType(type, mimeType) {
2275
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2276
+ const m = mimeType ?? "";
2277
+ if (m.startsWith("image/")) return "image";
2278
+ if (m.startsWith("video/")) return "video";
2279
+ if (m.startsWith("audio/")) return "audio";
2280
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2281
+ return "file";
2282
+ }
2283
+ /**
2284
+ * Map inbound user attachments to the persisted reference shape (drops the
2285
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2286
+ * chat-bucket `id`, so all are persisted.
2287
+ */
2288
+ function userAttachmentsToStored(refs) {
2289
+ if (!refs?.length) return void 0;
2290
+ const out = [];
2291
+ for (const r of refs) {
2292
+ if (!r.id) continue;
2293
+ out.push({
2294
+ attachmentId: r.id,
2295
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2296
+ mimeType: r.mimeType,
2297
+ filename: r.filename ?? "file",
2298
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2299
+ });
2300
+ }
2301
+ return out.length ? out : void 0;
2302
+ }
2303
+ /**
2304
+ * Map resolved assistant media to persisted references. Only media uploaded to
2305
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2306
+ * has no bindable record and stays live-URL-only.
2307
+ */
2308
+ function assistantMediaToStored(media) {
2309
+ if (!media?.length) return void 0;
2310
+ const out = [];
2311
+ for (const m of media) {
2312
+ if (!m.attachmentId) continue;
2313
+ out.push({
2314
+ attachmentId: m.attachmentId,
2315
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2316
+ mimeType: m.mimeType ?? "application/octet-stream",
2317
+ filename: m.filename ?? "file",
2318
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2319
+ });
2320
+ }
2321
+ return out.length ? out : void 0;
2322
+ }
2323
+ /**
2324
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2325
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2326
+ * client resolves a short-lived signed URL on demand.
2327
+ */
2328
+ function replayAttachments(atts) {
2329
+ if (!atts?.length) return void 0;
2330
+ return atts.map((a) => ({
2331
+ id: a.attachmentId,
2332
+ type: a.type,
2333
+ mimeType: a.mimeType,
2334
+ filename: a.filename,
2335
+ ...typeof a.size === "number" ? { size: a.size } : {}
2336
+ }));
2337
+ }
2204
2338
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2205
2339
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2206
2340
  const MAX_REDIRECTS = 5;
@@ -2450,7 +2584,7 @@ async function handleAgentRequest(request, log) {
2450
2584
  const cfg = runtime.config.loadConfig();
2451
2585
  const sessionId = conversationId ?? legacySessionKey;
2452
2586
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2453
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2587
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2454
2588
  const runGate = {
2455
2589
  runId: null,
2456
2590
  sessionKey: null
@@ -2686,7 +2820,7 @@ async function handleAgentRequest(request, log) {
2686
2820
  const note = formatMediaFailureNote(media.newFailures);
2687
2821
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2688
2822
  }
2689
- await addMessage(sessionId, "assistant", responseText);
2823
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2690
2824
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2691
2825
  chatClient?.notify("agent-message", {
2692
2826
  conversationId: conversationId ?? legacySessionKey,
@@ -2799,7 +2933,8 @@ async function handleSessionsGet(request, log) {
2799
2933
  role: m.role,
2800
2934
  content: m.content,
2801
2935
  timestamp: m.timestamp,
2802
- ...m.components?.length ? { components: m.components } : {}
2936
+ ...m.components?.length ? { components: m.components } : {},
2937
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2803
2938
  })),
2804
2939
  activity: await resolveHistoryActivity(session)
2805
2940
  });
@@ -2843,16 +2978,31 @@ const plugin = {
2843
2978
  log.info(`Registered ${String(a2aTools.length)} agent tools`);
2844
2979
  }
2845
2980
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2846
- const startChatService = () => {
2847
- if (globalThis.__alfeChatPluginActivated === true) {
2848
- log.debug("Chat plugin already activated — skipping duplicate");
2981
+ const startChatService = async () => {
2982
+ const action = decideChatServiceStart({
2983
+ connectInFlight: connectingPromise !== null,
2984
+ clientPresent: chatClient !== null,
2985
+ clientConnected: chatClient?.isConnected ?? false
2986
+ });
2987
+ if (action === "skip") {
2988
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2849
2989
  return;
2850
2990
  }
2991
+ if (action === "recycle") {
2992
+ log.info("Chat service client present but not connected — recycling before reconnect");
2993
+ try {
2994
+ chatClient?.stop();
2995
+ } catch (err) {
2996
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2997
+ }
2998
+ chatClient = null;
2999
+ }
2851
3000
  globalThis.__alfeChatPluginActivated = true;
2852
3001
  log.info("Chat plugin registering...");
2853
- resolveOpenClawSdk(log);
3002
+ await resolveOpenClawSdk(log);
2854
3003
  pluginRuntime = api.runtime ?? null;
2855
- connectingPromise = Promise.resolve().then(() => {
3004
+ let thisConnect = null;
3005
+ thisConnect = Promise.resolve().then(() => {
2856
3006
  try {
2857
3007
  const { apiKey, chatWsUrl } = resolveAlfeChat({
2858
3008
  apiKey: pluginConfig.apiKey,
@@ -2900,7 +3050,10 @@ const plugin = {
2900
3050
  } catch (err) {
2901
3051
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2902
3052
  }
3053
+ }).finally(() => {
3054
+ if (connectingPromise === thisConnect) connectingPromise = null;
2903
3055
  });
3056
+ connectingPromise = thisConnect;
2904
3057
  };
2905
3058
  const stopChatService = async () => {
2906
3059
  globalThis.__alfeChatPluginActivated = false;
@@ -2952,7 +3105,8 @@ const plugin = {
2952
3105
  role: m.role,
2953
3106
  content: m.content,
2954
3107
  timestamp: m.timestamp,
2955
- ...m.components?.length ? { components: m.components } : {}
3108
+ ...m.components?.length ? { components: m.components } : {},
3109
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2956
3110
  })),
2957
3111
  activity: await resolveHistoryActivity(session)
2958
3112
  };
@@ -3013,4 +3167,4 @@ const plugin = {
3013
3167
  }
3014
3168
  };
3015
3169
  //#endregion
3016
- export { __setActiveRunForTest as a, buildOriginEnvelopeContext as c, joinAssistantText as d, plugin as f, createAlfeChannelPlugin as g, stripLeakedToolSummary as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, rewriteAssistantText as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, resolveAbortTargetKeys as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, computeOpenClawSdkAnchors as u };
3170
+ export { createAlfeChannelPlugin as _, __setActiveRunForTest as a, buildOriginEnvelopeContext as c, decideChatServiceStart as d, joinAssistantText as f, stripLeakedToolSummary as g, rewriteAssistantText as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, resolveAbortTargetKeys as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, plugin as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, computeOpenClawSdkAnchors as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -27,7 +27,7 @@
27
27
  "openclaw.plugin.json"
28
28
  ],
29
29
  "dependencies": {
30
- "@alfe.ai/agent-api-client": "^0.10.0",
30
+ "@alfe.ai/agent-api-client": "^0.11.0",
31
31
  "@alfe.ai/chat": "^0.0.16",
32
32
  "@alfe.ai/config": "^0.3.0"
33
33
  },