@alfe.ai/openclaw-chat 0.9.3 → 0.9.5

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 SessionSummary, S as SessionData, _ as createAlfeChannelPlugin, b as AlfeResolvedAccount, p as plugin, v as AlfeChannelConfig, x as ChatMessage, y as AlfePluginConfig } 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 SessionSummary, S as SessionData, _ as createAlfeChannelPlugin, b as AlfeResolvedAccount, p as plugin, v as AlfeChannelConfig, x as ChatMessage, y as AlfePluginConfig } 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 { d as plugin, h 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
@@ -12,6 +12,8 @@ exports.admitAgentEvent = require_plugin.admitAgentEvent;
12
12
  exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
13
13
  exports.buildOriginEnvelopeContext = require_plugin.buildOriginEnvelopeContext;
14
14
  exports.buildToolActivity = require_plugin.buildToolActivity;
15
+ exports.computeOpenClawSdkAnchors = require_plugin.computeOpenClawSdkAnchors;
16
+ exports.decideChatServiceStart = require_plugin.decideChatServiceStart;
15
17
  exports.default = require_plugin.plugin;
16
18
  exports.joinAssistantText = require_plugin.joinAssistantText;
17
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
@@ -568,6 +588,64 @@ interface PluginApi {
568
588
  priority?: number;
569
589
  }): void;
570
590
  }
591
+ /**
592
+ * Injectable seams for anchor computation — real implementations resolve
593
+ * symlinks and locate the `openclaw` bin. Overridable so the pure anchor
594
+ * logic can be unit-tested without a homebrew/openclaw layout on disk.
595
+ */
596
+ interface AnchorEnv {
597
+ realpath: (p: string) => string;
598
+ which: (cmd: string) => string | undefined;
599
+ requireMainFilename: string | undefined;
600
+ argv1: string | undefined;
601
+ execPath: string;
602
+ }
603
+ /**
604
+ * Compute the ordered, de-duplicated list of resolver anchors to try, each
605
+ * best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
606
+ * → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
607
+ * whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
608
+ *
609
+ * Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
610
+ * 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
611
+ * real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
612
+ * makes it a usable anchor.
613
+ * 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
614
+ * homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
615
+ * `node_modules` sibling); its realpath lands in the real
616
+ * `lib/node_modules/openclaw` tree where resolution succeeds.
617
+ * 3. execPath-derived global-modules path (the original Strategy 2), plus a
618
+ * realpath'd variant of the execPath prefix for relocated-node layouts.
619
+ *
620
+ * Pure except for the injected `realpath` / `which` seams — unit-tested.
621
+ */
622
+ declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
623
+ /**
624
+ * Decide what `startChatService()` should do given the current connection
625
+ * state. Pure so the branch logic — the load-bearing fix for the
626
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
627
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
628
+ *
629
+ * OpenClaw calls the registered service's `start()` in two very different
630
+ * situations that a sticky activation flag could not tell apart:
631
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
632
+ * → must be a no-op ('skip'), and
633
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
634
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
635
+ * with no preceding `stop()` → must actually reconnect.
636
+ *
637
+ * Keying on the live client fixes it:
638
+ * - a connect in flight, or an already-connected client → 'skip'
639
+ * - a client that exists but is not connected → 'recycle' it then reconnect
640
+ * - no client at all → 'connect'
641
+ *
642
+ * Exported for unit tests.
643
+ */
644
+ declare function decideChatServiceStart(state: {
645
+ connectInFlight: boolean;
646
+ clientPresent: boolean;
647
+ clientConnected: boolean;
648
+ }): 'skip' | 'recycle' | 'connect';
571
649
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
572
650
  coalesceKey?: string;
573
651
  onSuperseded?: () => void;
@@ -649,4 +727,4 @@ declare const plugin: {
649
727
  deactivate(api: PluginApi): Promise<void>;
650
728
  };
651
729
  //#endregion
652
- export { SessionSummary as C, SessionData as S, createAlfeChannelPlugin as _, __agentTurnQueueForTest as a, AlfeResolvedAccount as b, admitAgentEvent as c, buildToolActivity as d, joinAssistantText as f, stripLeakedToolSummary as g, rewriteAssistantText as h, VOICE_REPLY_SYSTEM_PROMPT as i, buildA2ACompletePayload as l, resolveAbortTargetKeys as m, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as n, __resetAgentTurnQueueForTest as o, plugin as p, RunEventGate as r, __setActiveRunForTest as s, ActiveRunRef as t, buildOriginEnvelopeContext as u, AlfeChannelConfig as v, ChatMessage as x, AlfePluginConfig as y };
730
+ 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
@@ -568,6 +588,64 @@ interface PluginApi {
568
588
  priority?: number;
569
589
  }): void;
570
590
  }
591
+ /**
592
+ * Injectable seams for anchor computation — real implementations resolve
593
+ * symlinks and locate the `openclaw` bin. Overridable so the pure anchor
594
+ * logic can be unit-tested without a homebrew/openclaw layout on disk.
595
+ */
596
+ interface AnchorEnv {
597
+ realpath: (p: string) => string;
598
+ which: (cmd: string) => string | undefined;
599
+ requireMainFilename: string | undefined;
600
+ argv1: string | undefined;
601
+ execPath: string;
602
+ }
603
+ /**
604
+ * Compute the ordered, de-duplicated list of resolver anchors to try, each
605
+ * best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
606
+ * → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
607
+ * whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
608
+ *
609
+ * Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
610
+ * 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
611
+ * real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
612
+ * makes it a usable anchor.
613
+ * 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
614
+ * homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
615
+ * `node_modules` sibling); its realpath lands in the real
616
+ * `lib/node_modules/openclaw` tree where resolution succeeds.
617
+ * 3. execPath-derived global-modules path (the original Strategy 2), plus a
618
+ * realpath'd variant of the execPath prefix for relocated-node layouts.
619
+ *
620
+ * Pure except for the injected `realpath` / `which` seams — unit-tested.
621
+ */
622
+ declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
623
+ /**
624
+ * Decide what `startChatService()` should do given the current connection
625
+ * state. Pure so the branch logic — the load-bearing fix for the
626
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
627
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
628
+ *
629
+ * OpenClaw calls the registered service's `start()` in two very different
630
+ * situations that a sticky activation flag could not tell apart:
631
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
632
+ * → must be a no-op ('skip'), and
633
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
634
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
635
+ * with no preceding `stop()` → must actually reconnect.
636
+ *
637
+ * Keying on the live client fixes it:
638
+ * - a connect in flight, or an already-connected client → 'skip'
639
+ * - a client that exists but is not connected → 'recycle' it then reconnect
640
+ * - no client at all → 'connect'
641
+ *
642
+ * Exported for unit tests.
643
+ */
644
+ declare function decideChatServiceStart(state: {
645
+ connectInFlight: boolean;
646
+ clientPresent: boolean;
647
+ clientConnected: boolean;
648
+ }): 'skip' | 'recycle' | 'connect';
571
649
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
572
650
  coalesceKey?: string;
573
651
  onSuperseded?: () => void;
@@ -649,4 +727,4 @@ declare const plugin: {
649
727
  deactivate(api: PluginApi): Promise<void>;
650
728
  };
651
729
  //#endregion
652
- export { SessionSummary as C, SessionData as S, createAlfeChannelPlugin as _, __agentTurnQueueForTest as a, AlfeResolvedAccount as b, admitAgentEvent as c, buildToolActivity as d, joinAssistantText as f, stripLeakedToolSummary as g, rewriteAssistantText as h, VOICE_REPLY_SYSTEM_PROMPT as i, buildA2ACompletePayload as l, resolveAbortTargetKeys as m, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as n, __resetAgentTurnQueueForTest as o, plugin as p, RunEventGate as r, __setActiveRunForTest as s, ActiveRunRef as t, buildOriginEnvelopeContext as u, AlfeChannelConfig as v, ChatMessage as x, AlfePluginConfig as y };
730
+ 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 plugin, f as resolveAbortTargetKeys, i as __resetAgentTurnQueueForTest, l as buildToolActivity, m as stripLeakedToolSummary, n as VOICE_REPLY_SYSTEM_PROMPT, o as admitAgentEvent, p as rewriteAssistantText, r as __agentTurnQueueForTest, s as buildA2ACompletePayload, t as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, u as joinAssistantText } from "./plugin2.js";
2
- export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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
@@ -1,12 +1,13 @@
1
1
  let node_module = require("node:module");
2
2
  let node_fs_promises = require("node:fs/promises");
3
+ let node_fs = require("node:fs");
4
+ let node_child_process = require("node:child_process");
3
5
  let node_path = require("node:path");
4
6
  let node_os = require("node:os");
5
7
  let _alfe_ai_chat = require("@alfe.ai/chat");
6
8
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
7
9
  let node_crypto = require("node:crypto");
8
10
  let _alfe_ai_config = require("@alfe.ai/config");
9
- let node_fs = require("node:fs");
10
11
  //#region src/outbound-media.ts
11
12
  /**
12
13
  * Outbound media upload — agent → user.
@@ -209,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
209
210
  url: presigned.downloadUrl,
210
211
  filename,
211
212
  mimeType,
212
- size
213
+ size,
214
+ attachmentId: presigned.id
213
215
  };
214
216
  } catch (err) {
215
217
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -873,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
873
875
  return session;
874
876
  }
875
877
  const writeLocks = /* @__PURE__ */ new Map();
876
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
878
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
877
879
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
878
880
  const session = await getSession(sessionId);
879
881
  if (!session) return;
@@ -883,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
883
885
  timestamp: Date.now(),
884
886
  ...senderId ? { senderId } : {},
885
887
  ...senderName ? { senderName } : {},
886
- ...components?.length ? { components } : {}
888
+ ...components?.length ? { components } : {},
889
+ ...attachments?.length ? { attachments } : {}
887
890
  });
888
891
  await saveSession(session);
889
892
  }).finally(() => {
@@ -2091,27 +2094,214 @@ function loadOpenClawSdk(req, log, anchor) {
2091
2094
  return resolvedDispatch;
2092
2095
  }
2093
2096
  /**
2097
+ * Default `which openclaw` implementation. Prefers the shell's own resolver
2098
+ * (`command -v` / `which`), then falls back to scanning `PATH` for an
2099
+ * `openclaw` executable. Returns undefined if it can't be located — every
2100
+ * caller treats a missing anchor as "skip", never as fatal.
2101
+ */
2102
+ function whichOpenClaw(pathEnv) {
2103
+ for (const probe of [{
2104
+ cmd: "command",
2105
+ args: ["-v", "openclaw"]
2106
+ }, {
2107
+ cmd: "which",
2108
+ args: ["openclaw"]
2109
+ }]) try {
2110
+ const out = (0, node_child_process.execFileSync)(probe.cmd, probe.args, {
2111
+ encoding: "utf8",
2112
+ stdio: [
2113
+ "ignore",
2114
+ "pipe",
2115
+ "ignore"
2116
+ ]
2117
+ }).trim().split("\n")[0]?.trim();
2118
+ if (out) return out;
2119
+ } catch {}
2120
+ for (const dir of (pathEnv ?? "").split(node_path.delimiter)) {
2121
+ if (!dir) continue;
2122
+ const candidate = (0, node_path.join)(dir, "openclaw");
2123
+ try {
2124
+ (0, node_fs.realpathSync)(candidate);
2125
+ return candidate;
2126
+ } catch {}
2127
+ }
2128
+ }
2129
+ /**
2130
+ * Compute the ordered, de-duplicated list of resolver anchors to try, each
2131
+ * best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
2132
+ * → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
2133
+ * whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
2134
+ *
2135
+ * Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
2136
+ * 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
2137
+ * real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
2138
+ * makes it a usable anchor.
2139
+ * 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
2140
+ * homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
2141
+ * `node_modules` sibling); its realpath lands in the real
2142
+ * `lib/node_modules/openclaw` tree where resolution succeeds.
2143
+ * 3. execPath-derived global-modules path (the original Strategy 2), plus a
2144
+ * realpath'd variant of the execPath prefix for relocated-node layouts.
2145
+ *
2146
+ * Pure except for the injected `realpath` / `which` seams — unit-tested.
2147
+ */
2148
+ function computeOpenClawSdkAnchors(deps) {
2149
+ const anchors = [];
2150
+ const push = (p) => {
2151
+ if (!p) return;
2152
+ let real;
2153
+ try {
2154
+ real = deps.realpath(p);
2155
+ } catch {}
2156
+ for (const candidate of [real, p]) if (candidate && !anchors.includes(candidate)) anchors.push(candidate);
2157
+ };
2158
+ push(deps.requireMainFilename);
2159
+ push(deps.argv1);
2160
+ try {
2161
+ push(deps.which("openclaw"));
2162
+ } catch {}
2163
+ for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
2164
+ if (!base) continue;
2165
+ try {
2166
+ const derived = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(base), ".."), "lib", "node_modules", "openclaw", "package.json");
2167
+ if (!anchors.includes(derived)) anchors.push(derived);
2168
+ } catch {}
2169
+ }
2170
+ return anchors;
2171
+ }
2172
+ function safeRealpath(realpath, p) {
2173
+ try {
2174
+ const r = realpath(p);
2175
+ return r === p ? void 0 : r;
2176
+ } catch {
2177
+ return;
2178
+ }
2179
+ }
2180
+ /**
2094
2181
  * Resolve OpenClaw SDK functions from the running process.
2095
2182
  *
2096
2183
  * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
2097
2184
  * Since the plugin runs inside OpenClaw's process, we anchor resolution to
2098
- * OpenClaw's own entry module via require.main, then fall back to deriving
2099
- * the global modules path from process.execPath.
2185
+ * OpenClaw's own realpath'd entry (surviving bin-symlink / homebrew-keg /
2186
+ * relocated-node layouts), then to a `which openclaw` lookup, then fall back to
2187
+ * deriving the global modules path from process.execPath.
2100
2188
  */
2101
2189
  function resolveOpenClawSdk(log) {
2102
- const anchors = [require$1.main?.filename, process.argv[1]].filter(Boolean);
2190
+ const pathEnv = process.env.PATH;
2191
+ const anchors = computeOpenClawSdkAnchors({
2192
+ realpath: node_fs.realpathSync,
2193
+ which: () => whichOpenClaw(pathEnv),
2194
+ requireMainFilename: require$1.main?.filename,
2195
+ argv1: process.argv[1],
2196
+ execPath: process.execPath
2197
+ });
2103
2198
  for (const anchor of anchors) try {
2104
2199
  if (loadOpenClawSdk((0, node_module.createRequire)(anchor), log, anchor)) return;
2105
2200
  } catch {}
2106
- try {
2107
- const derivedPath = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
2108
- if (loadOpenClawSdk((0, node_module.createRequire)(derivedPath), log, derivedPath)) return;
2109
- } catch {}
2110
2201
  log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
2111
2202
  }
2112
2203
  let pluginRuntime = null;
2113
2204
  let chatClient = null;
2114
2205
  let connectingPromise = null;
2206
+ /**
2207
+ * Decide what `startChatService()` should do given the current connection
2208
+ * state. Pure so the branch logic — the load-bearing fix for the
2209
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2210
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2211
+ *
2212
+ * OpenClaw calls the registered service's `start()` in two very different
2213
+ * situations that a sticky activation flag could not tell apart:
2214
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2215
+ * → must be a no-op ('skip'), and
2216
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2217
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2218
+ * with no preceding `stop()` → must actually reconnect.
2219
+ *
2220
+ * Keying on the live client fixes it:
2221
+ * - a connect in flight, or an already-connected client → 'skip'
2222
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2223
+ * - no client at all → 'connect'
2224
+ *
2225
+ * Exported for unit tests.
2226
+ */
2227
+ function decideChatServiceStart(state) {
2228
+ if (state.connectInFlight) return "skip";
2229
+ if (state.clientPresent && state.clientConnected) return "skip";
2230
+ if (state.clientPresent) return "recycle";
2231
+ return "connect";
2232
+ }
2233
+ const STORED_ATTACHMENT_TYPES = new Set([
2234
+ "image",
2235
+ "video",
2236
+ "audio",
2237
+ "document",
2238
+ "file"
2239
+ ]);
2240
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2241
+ function coerceStoredAttachmentType(type, mimeType) {
2242
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2243
+ const m = mimeType ?? "";
2244
+ if (m.startsWith("image/")) return "image";
2245
+ if (m.startsWith("video/")) return "video";
2246
+ if (m.startsWith("audio/")) return "audio";
2247
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2248
+ return "file";
2249
+ }
2250
+ /**
2251
+ * Map inbound user attachments to the persisted reference shape (drops the
2252
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2253
+ * chat-bucket `id`, so all are persisted.
2254
+ */
2255
+ function userAttachmentsToStored(refs) {
2256
+ if (!refs?.length) return void 0;
2257
+ const out = [];
2258
+ for (const r of refs) {
2259
+ if (!r.id) continue;
2260
+ out.push({
2261
+ attachmentId: r.id,
2262
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2263
+ mimeType: r.mimeType,
2264
+ filename: r.filename ?? "file",
2265
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2266
+ });
2267
+ }
2268
+ return out.length ? out : void 0;
2269
+ }
2270
+ /**
2271
+ * Map resolved assistant media to persisted references. Only media uploaded to
2272
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2273
+ * has no bindable record and stays live-URL-only.
2274
+ */
2275
+ function assistantMediaToStored(media) {
2276
+ if (!media?.length) return void 0;
2277
+ const out = [];
2278
+ for (const m of media) {
2279
+ if (!m.attachmentId) continue;
2280
+ out.push({
2281
+ attachmentId: m.attachmentId,
2282
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2283
+ mimeType: m.mimeType ?? "application/octet-stream",
2284
+ filename: m.filename ?? "file",
2285
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2286
+ });
2287
+ }
2288
+ return out.length ? out : void 0;
2289
+ }
2290
+ /**
2291
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2292
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2293
+ * client resolves a short-lived signed URL on demand.
2294
+ */
2295
+ function replayAttachments(atts) {
2296
+ if (!atts?.length) return void 0;
2297
+ return atts.map((a) => ({
2298
+ id: a.attachmentId,
2299
+ type: a.type,
2300
+ mimeType: a.mimeType,
2301
+ filename: a.filename,
2302
+ ...typeof a.size === "number" ? { size: a.size } : {}
2303
+ }));
2304
+ }
2115
2305
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2116
2306
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2117
2307
  const MAX_REDIRECTS = 5;
@@ -2361,7 +2551,7 @@ async function handleAgentRequest(request, log) {
2361
2551
  const cfg = runtime.config.loadConfig();
2362
2552
  const sessionId = conversationId ?? legacySessionKey;
2363
2553
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2364
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2554
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2365
2555
  const runGate = {
2366
2556
  runId: null,
2367
2557
  sessionKey: null
@@ -2597,7 +2787,7 @@ async function handleAgentRequest(request, log) {
2597
2787
  const note = formatMediaFailureNote(media.newFailures);
2598
2788
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2599
2789
  }
2600
- await addMessage(sessionId, "assistant", responseText);
2790
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2601
2791
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2602
2792
  chatClient?.notify("agent-message", {
2603
2793
  conversationId: conversationId ?? legacySessionKey,
@@ -2710,7 +2900,8 @@ async function handleSessionsGet(request, log) {
2710
2900
  role: m.role,
2711
2901
  content: m.content,
2712
2902
  timestamp: m.timestamp,
2713
- ...m.components?.length ? { components: m.components } : {}
2903
+ ...m.components?.length ? { components: m.components } : {},
2904
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2714
2905
  })),
2715
2906
  activity: await resolveHistoryActivity(session)
2716
2907
  });
@@ -2755,15 +2946,30 @@ const plugin = {
2755
2946
  }
2756
2947
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2757
2948
  const startChatService = () => {
2758
- if (globalThis.__alfeChatPluginActivated === true) {
2759
- log.debug("Chat plugin already activated — skipping duplicate");
2949
+ const action = decideChatServiceStart({
2950
+ connectInFlight: connectingPromise !== null,
2951
+ clientPresent: chatClient !== null,
2952
+ clientConnected: chatClient?.isConnected ?? false
2953
+ });
2954
+ if (action === "skip") {
2955
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2760
2956
  return;
2761
2957
  }
2958
+ if (action === "recycle") {
2959
+ log.info("Chat service client present but not connected — recycling before reconnect");
2960
+ try {
2961
+ chatClient?.stop();
2962
+ } catch (err) {
2963
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2964
+ }
2965
+ chatClient = null;
2966
+ }
2762
2967
  globalThis.__alfeChatPluginActivated = true;
2763
2968
  log.info("Chat plugin registering...");
2764
2969
  resolveOpenClawSdk(log);
2765
2970
  pluginRuntime = api.runtime ?? null;
2766
- connectingPromise = Promise.resolve().then(() => {
2971
+ let thisConnect = null;
2972
+ thisConnect = Promise.resolve().then(() => {
2767
2973
  try {
2768
2974
  const { apiKey, chatWsUrl } = (0, _alfe_ai_chat.resolveAlfeChat)({
2769
2975
  apiKey: pluginConfig.apiKey,
@@ -2811,7 +3017,10 @@ const plugin = {
2811
3017
  } catch (err) {
2812
3018
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2813
3019
  }
3020
+ }).finally(() => {
3021
+ if (connectingPromise === thisConnect) connectingPromise = null;
2814
3022
  });
3023
+ connectingPromise = thisConnect;
2815
3024
  };
2816
3025
  const stopChatService = async () => {
2817
3026
  globalThis.__alfeChatPluginActivated = false;
@@ -2863,13 +3072,15 @@ const plugin = {
2863
3072
  role: m.role,
2864
3073
  content: m.content,
2865
3074
  timestamp: m.timestamp,
2866
- ...m.components?.length ? { components: m.components } : {}
3075
+ ...m.components?.length ? { components: m.components } : {},
3076
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2867
3077
  })),
2868
3078
  activity: await resolveHistoryActivity(session)
2869
3079
  };
2870
3080
  });
3081
+ api.registerGatewayMethod("runtime.activity", () => Promise.resolve({ active: activeRun !== null }));
2871
3082
  gw.__alfeChatGatewayMethodsRegistered = true;
2872
- log.info("Registered gateway RPC methods: sessions.list, sessions.get");
3083
+ log.info("Registered gateway RPC methods: sessions.list, sessions.get, runtime.activity");
2873
3084
  }
2874
3085
  api.on("session_start", async (...eventArgs) => {
2875
3086
  if (!pluginRuntime) return;
@@ -2977,12 +3188,24 @@ Object.defineProperty(exports, "buildToolActivity", {
2977
3188
  return buildToolActivity;
2978
3189
  }
2979
3190
  });
3191
+ Object.defineProperty(exports, "computeOpenClawSdkAnchors", {
3192
+ enumerable: true,
3193
+ get: function() {
3194
+ return computeOpenClawSdkAnchors;
3195
+ }
3196
+ });
2980
3197
  Object.defineProperty(exports, "createAlfeChannelPlugin", {
2981
3198
  enumerable: true,
2982
3199
  get: function() {
2983
3200
  return createAlfeChannelPlugin;
2984
3201
  }
2985
3202
  });
3203
+ Object.defineProperty(exports, "decideChatServiceStart", {
3204
+ enumerable: true,
3205
+ get: function() {
3206
+ return decideChatServiceStart;
3207
+ }
3208
+ });
2986
3209
  Object.defineProperty(exports, "joinAssistantText", {
2987
3210
  enumerable: true,
2988
3211
  get: function() {
@@ -1,2 +1,2 @@
1
- import { a as __agentTurnQueueForTest, c as admitAgentEvent, d as buildToolActivity, f as joinAssistantText, g as stripLeakedToolSummary, h as rewriteAssistantText, i as VOICE_REPLY_SYSTEM_PROMPT, l as buildA2ACompletePayload, m as resolveAbortTargetKeys, n as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, o as __resetAgentTurnQueueForTest, p as plugin, r as RunEventGate, s as __setActiveRunForTest, t as ActiveRunRef, u as buildOriginEnvelopeContext } from "./plugin.cjs";
2
- export { ActiveRunRef, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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 { a as __agentTurnQueueForTest, c as admitAgentEvent, d as buildToolActivity, f as joinAssistantText, g as stripLeakedToolSummary, h as rewriteAssistantText, i as VOICE_REPLY_SYSTEM_PROMPT, l as buildA2ACompletePayload, m as resolveAbortTargetKeys, n as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, o as __resetAgentTurnQueueForTest, p as plugin, r as RunEventGate, s as __setActiveRunForTest, t as ActiveRunRef, u as buildOriginEnvelopeContext } from "./plugin.js";
2
- export { ActiveRunRef, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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
@@ -1,12 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
5
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
6
  import { homedir } from "node:os";
5
7
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
6
8
  import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
7
9
  import { randomUUID } from "node:crypto";
8
10
  import { resolveConfig } from "@alfe.ai/config";
9
- import { existsSync } from "node:fs";
10
11
  //#region src/outbound-media.ts
11
12
  /**
12
13
  * Outbound media upload — agent → user.
@@ -209,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
209
210
  url: presigned.downloadUrl,
210
211
  filename,
211
212
  mimeType,
212
- size
213
+ size,
214
+ attachmentId: presigned.id
213
215
  };
214
216
  } catch (err) {
215
217
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -873,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
873
875
  return session;
874
876
  }
875
877
  const writeLocks = /* @__PURE__ */ new Map();
876
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
878
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
877
879
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
878
880
  const session = await getSession(sessionId);
879
881
  if (!session) return;
@@ -883,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
883
885
  timestamp: Date.now(),
884
886
  ...senderId ? { senderId } : {},
885
887
  ...senderName ? { senderName } : {},
886
- ...components?.length ? { components } : {}
888
+ ...components?.length ? { components } : {},
889
+ ...attachments?.length ? { attachments } : {}
887
890
  });
888
891
  await saveSession(session);
889
892
  }).finally(() => {
@@ -2091,27 +2094,214 @@ function loadOpenClawSdk(req, log, anchor) {
2091
2094
  return resolvedDispatch;
2092
2095
  }
2093
2096
  /**
2097
+ * Default `which openclaw` implementation. Prefers the shell's own resolver
2098
+ * (`command -v` / `which`), then falls back to scanning `PATH` for an
2099
+ * `openclaw` executable. Returns undefined if it can't be located — every
2100
+ * caller treats a missing anchor as "skip", never as fatal.
2101
+ */
2102
+ function whichOpenClaw(pathEnv) {
2103
+ for (const probe of [{
2104
+ cmd: "command",
2105
+ args: ["-v", "openclaw"]
2106
+ }, {
2107
+ cmd: "which",
2108
+ args: ["openclaw"]
2109
+ }]) try {
2110
+ const out = execFileSync(probe.cmd, probe.args, {
2111
+ encoding: "utf8",
2112
+ stdio: [
2113
+ "ignore",
2114
+ "pipe",
2115
+ "ignore"
2116
+ ]
2117
+ }).trim().split("\n")[0]?.trim();
2118
+ if (out) return out;
2119
+ } catch {}
2120
+ for (const dir of (pathEnv ?? "").split(delimiter)) {
2121
+ if (!dir) continue;
2122
+ const candidate = join(dir, "openclaw");
2123
+ try {
2124
+ realpathSync(candidate);
2125
+ return candidate;
2126
+ } catch {}
2127
+ }
2128
+ }
2129
+ /**
2130
+ * Compute the ordered, de-duplicated list of resolver anchors to try, each
2131
+ * best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
2132
+ * → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
2133
+ * whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
2134
+ *
2135
+ * Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
2136
+ * 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
2137
+ * real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
2138
+ * makes it a usable anchor.
2139
+ * 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
2140
+ * homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
2141
+ * `node_modules` sibling); its realpath lands in the real
2142
+ * `lib/node_modules/openclaw` tree where resolution succeeds.
2143
+ * 3. execPath-derived global-modules path (the original Strategy 2), plus a
2144
+ * realpath'd variant of the execPath prefix for relocated-node layouts.
2145
+ *
2146
+ * Pure except for the injected `realpath` / `which` seams — unit-tested.
2147
+ */
2148
+ function computeOpenClawSdkAnchors(deps) {
2149
+ const anchors = [];
2150
+ const push = (p) => {
2151
+ if (!p) return;
2152
+ let real;
2153
+ try {
2154
+ real = deps.realpath(p);
2155
+ } catch {}
2156
+ for (const candidate of [real, p]) if (candidate && !anchors.includes(candidate)) anchors.push(candidate);
2157
+ };
2158
+ push(deps.requireMainFilename);
2159
+ push(deps.argv1);
2160
+ try {
2161
+ push(deps.which("openclaw"));
2162
+ } catch {}
2163
+ for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
2164
+ if (!base) continue;
2165
+ try {
2166
+ const derived = join(resolve(dirname(base), ".."), "lib", "node_modules", "openclaw", "package.json");
2167
+ if (!anchors.includes(derived)) anchors.push(derived);
2168
+ } catch {}
2169
+ }
2170
+ return anchors;
2171
+ }
2172
+ function safeRealpath(realpath, p) {
2173
+ try {
2174
+ const r = realpath(p);
2175
+ return r === p ? void 0 : r;
2176
+ } catch {
2177
+ return;
2178
+ }
2179
+ }
2180
+ /**
2094
2181
  * Resolve OpenClaw SDK functions from the running process.
2095
2182
  *
2096
2183
  * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
2097
2184
  * Since the plugin runs inside OpenClaw's process, we anchor resolution to
2098
- * OpenClaw's own entry module via require.main, then fall back to deriving
2099
- * the global modules path from process.execPath.
2185
+ * OpenClaw's own realpath'd entry (surviving bin-symlink / homebrew-keg /
2186
+ * relocated-node layouts), then to a `which openclaw` lookup, then fall back to
2187
+ * deriving the global modules path from process.execPath.
2100
2188
  */
2101
2189
  function resolveOpenClawSdk(log) {
2102
- const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
2190
+ const pathEnv = process.env.PATH;
2191
+ const anchors = computeOpenClawSdkAnchors({
2192
+ realpath: realpathSync,
2193
+ which: () => whichOpenClaw(pathEnv),
2194
+ requireMainFilename: require.main?.filename,
2195
+ argv1: process.argv[1],
2196
+ execPath: process.execPath
2197
+ });
2103
2198
  for (const anchor of anchors) try {
2104
2199
  if (loadOpenClawSdk(createRequire(anchor), log, anchor)) return;
2105
2200
  } catch {}
2106
- try {
2107
- const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
2108
- if (loadOpenClawSdk(createRequire(derivedPath), log, derivedPath)) return;
2109
- } catch {}
2110
2201
  log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
2111
2202
  }
2112
2203
  let pluginRuntime = null;
2113
2204
  let chatClient = null;
2114
2205
  let connectingPromise = null;
2206
+ /**
2207
+ * Decide what `startChatService()` should do given the current connection
2208
+ * state. Pure so the branch logic — the load-bearing fix for the
2209
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2210
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2211
+ *
2212
+ * OpenClaw calls the registered service's `start()` in two very different
2213
+ * situations that a sticky activation flag could not tell apart:
2214
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2215
+ * → must be a no-op ('skip'), and
2216
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2217
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2218
+ * with no preceding `stop()` → must actually reconnect.
2219
+ *
2220
+ * Keying on the live client fixes it:
2221
+ * - a connect in flight, or an already-connected client → 'skip'
2222
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2223
+ * - no client at all → 'connect'
2224
+ *
2225
+ * Exported for unit tests.
2226
+ */
2227
+ function decideChatServiceStart(state) {
2228
+ if (state.connectInFlight) return "skip";
2229
+ if (state.clientPresent && state.clientConnected) return "skip";
2230
+ if (state.clientPresent) return "recycle";
2231
+ return "connect";
2232
+ }
2233
+ const STORED_ATTACHMENT_TYPES = new Set([
2234
+ "image",
2235
+ "video",
2236
+ "audio",
2237
+ "document",
2238
+ "file"
2239
+ ]);
2240
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2241
+ function coerceStoredAttachmentType(type, mimeType) {
2242
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2243
+ const m = mimeType ?? "";
2244
+ if (m.startsWith("image/")) return "image";
2245
+ if (m.startsWith("video/")) return "video";
2246
+ if (m.startsWith("audio/")) return "audio";
2247
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2248
+ return "file";
2249
+ }
2250
+ /**
2251
+ * Map inbound user attachments to the persisted reference shape (drops the
2252
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2253
+ * chat-bucket `id`, so all are persisted.
2254
+ */
2255
+ function userAttachmentsToStored(refs) {
2256
+ if (!refs?.length) return void 0;
2257
+ const out = [];
2258
+ for (const r of refs) {
2259
+ if (!r.id) continue;
2260
+ out.push({
2261
+ attachmentId: r.id,
2262
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2263
+ mimeType: r.mimeType,
2264
+ filename: r.filename ?? "file",
2265
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2266
+ });
2267
+ }
2268
+ return out.length ? out : void 0;
2269
+ }
2270
+ /**
2271
+ * Map resolved assistant media to persisted references. Only media uploaded to
2272
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2273
+ * has no bindable record and stays live-URL-only.
2274
+ */
2275
+ function assistantMediaToStored(media) {
2276
+ if (!media?.length) return void 0;
2277
+ const out = [];
2278
+ for (const m of media) {
2279
+ if (!m.attachmentId) continue;
2280
+ out.push({
2281
+ attachmentId: m.attachmentId,
2282
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2283
+ mimeType: m.mimeType ?? "application/octet-stream",
2284
+ filename: m.filename ?? "file",
2285
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2286
+ });
2287
+ }
2288
+ return out.length ? out : void 0;
2289
+ }
2290
+ /**
2291
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2292
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2293
+ * client resolves a short-lived signed URL on demand.
2294
+ */
2295
+ function replayAttachments(atts) {
2296
+ if (!atts?.length) return void 0;
2297
+ return atts.map((a) => ({
2298
+ id: a.attachmentId,
2299
+ type: a.type,
2300
+ mimeType: a.mimeType,
2301
+ filename: a.filename,
2302
+ ...typeof a.size === "number" ? { size: a.size } : {}
2303
+ }));
2304
+ }
2115
2305
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2116
2306
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2117
2307
  const MAX_REDIRECTS = 5;
@@ -2361,7 +2551,7 @@ async function handleAgentRequest(request, log) {
2361
2551
  const cfg = runtime.config.loadConfig();
2362
2552
  const sessionId = conversationId ?? legacySessionKey;
2363
2553
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2364
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2554
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2365
2555
  const runGate = {
2366
2556
  runId: null,
2367
2557
  sessionKey: null
@@ -2597,7 +2787,7 @@ async function handleAgentRequest(request, log) {
2597
2787
  const note = formatMediaFailureNote(media.newFailures);
2598
2788
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2599
2789
  }
2600
- await addMessage(sessionId, "assistant", responseText);
2790
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2601
2791
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2602
2792
  chatClient?.notify("agent-message", {
2603
2793
  conversationId: conversationId ?? legacySessionKey,
@@ -2710,7 +2900,8 @@ async function handleSessionsGet(request, log) {
2710
2900
  role: m.role,
2711
2901
  content: m.content,
2712
2902
  timestamp: m.timestamp,
2713
- ...m.components?.length ? { components: m.components } : {}
2903
+ ...m.components?.length ? { components: m.components } : {},
2904
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2714
2905
  })),
2715
2906
  activity: await resolveHistoryActivity(session)
2716
2907
  });
@@ -2755,15 +2946,30 @@ const plugin = {
2755
2946
  }
2756
2947
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2757
2948
  const startChatService = () => {
2758
- if (globalThis.__alfeChatPluginActivated === true) {
2759
- log.debug("Chat plugin already activated — skipping duplicate");
2949
+ const action = decideChatServiceStart({
2950
+ connectInFlight: connectingPromise !== null,
2951
+ clientPresent: chatClient !== null,
2952
+ clientConnected: chatClient?.isConnected ?? false
2953
+ });
2954
+ if (action === "skip") {
2955
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2760
2956
  return;
2761
2957
  }
2958
+ if (action === "recycle") {
2959
+ log.info("Chat service client present but not connected — recycling before reconnect");
2960
+ try {
2961
+ chatClient?.stop();
2962
+ } catch (err) {
2963
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2964
+ }
2965
+ chatClient = null;
2966
+ }
2762
2967
  globalThis.__alfeChatPluginActivated = true;
2763
2968
  log.info("Chat plugin registering...");
2764
2969
  resolveOpenClawSdk(log);
2765
2970
  pluginRuntime = api.runtime ?? null;
2766
- connectingPromise = Promise.resolve().then(() => {
2971
+ let thisConnect = null;
2972
+ thisConnect = Promise.resolve().then(() => {
2767
2973
  try {
2768
2974
  const { apiKey, chatWsUrl } = resolveAlfeChat({
2769
2975
  apiKey: pluginConfig.apiKey,
@@ -2811,7 +3017,10 @@ const plugin = {
2811
3017
  } catch (err) {
2812
3018
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2813
3019
  }
3020
+ }).finally(() => {
3021
+ if (connectingPromise === thisConnect) connectingPromise = null;
2814
3022
  });
3023
+ connectingPromise = thisConnect;
2815
3024
  };
2816
3025
  const stopChatService = async () => {
2817
3026
  globalThis.__alfeChatPluginActivated = false;
@@ -2863,13 +3072,15 @@ const plugin = {
2863
3072
  role: m.role,
2864
3073
  content: m.content,
2865
3074
  timestamp: m.timestamp,
2866
- ...m.components?.length ? { components: m.components } : {}
3075
+ ...m.components?.length ? { components: m.components } : {},
3076
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2867
3077
  })),
2868
3078
  activity: await resolveHistoryActivity(session)
2869
3079
  };
2870
3080
  });
3081
+ api.registerGatewayMethod("runtime.activity", () => Promise.resolve({ active: activeRun !== null }));
2871
3082
  gw.__alfeChatGatewayMethodsRegistered = true;
2872
- log.info("Registered gateway RPC methods: sessions.list, sessions.get");
3083
+ log.info("Registered gateway RPC methods: sessions.list, sessions.get, runtime.activity");
2873
3084
  }
2874
3085
  api.on("session_start", async (...eventArgs) => {
2875
3086
  if (!pluginRuntime) return;
@@ -2923,4 +3134,4 @@ const plugin = {
2923
3134
  }
2924
3135
  };
2925
3136
  //#endregion
2926
- export { __setActiveRunForTest as a, buildOriginEnvelopeContext as c, plugin as d, resolveAbortTargetKeys as f, createAlfeChannelPlugin as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, stripLeakedToolSummary as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, rewriteAssistantText as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, joinAssistantText as u };
3137
+ 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.3",
3
+ "version": "0.9.5",
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.9.0",
30
+ "@alfe.ai/agent-api-client": "^0.10.0",
31
31
  "@alfe.ai/chat": "^0.0.16",
32
32
  "@alfe.ai/config": "^0.3.0"
33
33
  },