@newbase-clawchat/openclaw-clawchat 2026.4.15

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.
@@ -0,0 +1,276 @@
1
+ import { MockTransport, AuthError } from "@newbase-clawchat/sdk";
2
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import {
5
+ classifyClawlingClientError,
6
+ mapClawlingStateToStatus,
7
+ setOpenclawClawlingRuntime,
8
+ getOpenclawClawlingRuntime,
9
+ } from "./runtime.ts";
10
+
11
+ describe("openclaw-clawchat runtime helpers", () => {
12
+ it("maps SDK states to channel status shape", () => {
13
+ expect(mapClawlingStateToStatus("connected")).toMatchObject({
14
+ connected: true,
15
+ running: true,
16
+ });
17
+ expect(mapClawlingStateToStatus("reconnecting")).toMatchObject({
18
+ connected: false,
19
+ running: true,
20
+ });
21
+ expect(mapClawlingStateToStatus("disconnected")).toMatchObject({
22
+ connected: false,
23
+ running: false,
24
+ });
25
+ expect(mapClawlingStateToStatus("connecting")).toMatchObject({
26
+ connected: false,
27
+ running: true,
28
+ });
29
+ });
30
+
31
+ it("classifies AuthError as fatal/no-retry", () => {
32
+ const c = classifyClawlingClientError(new AuthError("hello-fail", "bad-token"));
33
+ expect(c.kind).toBe("auth");
34
+ expect(c.retry).toBe(false);
35
+ });
36
+
37
+ it("classifies generic errors as unknown", () => {
38
+ const c = classifyClawlingClientError(new Error("huh"));
39
+ expect(c.kind).toBe("unknown");
40
+ expect(c.retry).toBe(false);
41
+ });
42
+
43
+ it("runtime store round-trips", () => {
44
+ const rt = { mocked: true } as unknown as PluginRuntime;
45
+ setOpenclawClawlingRuntime(rt);
46
+ expect(getOpenclawClawlingRuntime()).toBe(rt);
47
+ });
48
+ });
49
+
50
+ describe("openclaw-clawchat runtime media ingest", () => {
51
+ it("fetches inbound media via runtime.channel.media and populates MediaPath/MediaPaths", async () => {
52
+ const fetched: Array<{ url: string }> = [];
53
+ const saved: Array<{ ct: string | undefined }> = [];
54
+ let capturedCtx: Record<string, unknown> | undefined;
55
+ const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
56
+ capturedCtx = ctx;
57
+ return ctx;
58
+ });
59
+
60
+ const runtime = {
61
+ channel: {
62
+ routing: {
63
+ resolveAgentRoute: vi.fn(() => ({
64
+ agentId: "u",
65
+ accountId: "default",
66
+ sessionKey: "s",
67
+ })),
68
+ },
69
+ session: {
70
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
71
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
72
+ },
73
+ reply: {
74
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
75
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
76
+ finalizeInboundContext,
77
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
78
+ createReplyDispatcherWithTyping: vi.fn(() => ({
79
+ dispatcher: {},
80
+ replyOptions: {},
81
+ markDispatchIdle: vi.fn(),
82
+ markRunComplete: vi.fn(),
83
+ })),
84
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
85
+ await opts.run();
86
+ }),
87
+ dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
88
+ },
89
+ media: {
90
+ fetchRemoteMedia: vi.fn(async ({ url }: { url: string }) => {
91
+ fetched.push({ url });
92
+ return { buffer: Buffer.from("x"), contentType: "image/png", fileName: "f.png" };
93
+ }),
94
+ saveMediaBuffer: vi.fn(async (_buf, ct?: string) => {
95
+ saved.push({ ct });
96
+ return { path: `/cache/${saved.length}.png`, contentType: "image/png" };
97
+ }),
98
+ loadWebMedia: vi.fn(),
99
+ },
100
+ },
101
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
102
+
103
+ setOpenclawClawlingRuntime(runtime);
104
+
105
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
106
+ const { MockTransport } = await import("@newbase-clawchat/sdk");
107
+ const transport = new MockTransport();
108
+ const abortController = new AbortController();
109
+
110
+ const startPromise = startOpenclawClawlingGateway({
111
+ cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
112
+ account: {
113
+ accountId: "default",
114
+ name: "openclaw-clawchat",
115
+ enabled: true,
116
+ configured: true,
117
+ websocketUrl: "ws://t",
118
+ baseUrl: "https://api.example.com",
119
+ token: "tk",
120
+ userId: "u",
121
+ replyMode: "static",
122
+ forwardThinking: true,
123
+ forwardToolCalls: false,
124
+ allowFrom: [],
125
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
126
+ reconnect: {
127
+ initialDelay: 1000,
128
+ maxDelay: 30000,
129
+ jitterRatio: 0.3,
130
+ maxRetries: Number.POSITIVE_INFINITY,
131
+ },
132
+ heartbeat: { interval: 25000, timeout: 10000 },
133
+ ack: { timeout: 10000, autoResendOnTimeout: false },
134
+ },
135
+ abortSignal: abortController.signal,
136
+ setStatus: vi.fn(),
137
+ getStatus: vi.fn(() => ({ accountId: "default" })),
138
+ log: { info: () => {}, error: () => {} },
139
+ transport,
140
+ });
141
+
142
+ await new Promise((r) => setTimeout(r, 0));
143
+ transport.emitInbound(
144
+ JSON.stringify({
145
+ version: "2",
146
+ event: "connect.challenge",
147
+ trace_id: "tc",
148
+ emitted_at: Date.now(),
149
+ payload: { nonce: "n" },
150
+ }),
151
+ );
152
+ transport.emitInbound(
153
+ JSON.stringify({
154
+ version: "2",
155
+ event: "hello-ok",
156
+ trace_id: "th",
157
+ emitted_at: Date.now(),
158
+ payload: {},
159
+ }),
160
+ );
161
+ await new Promise((r) => setTimeout(r, 5));
162
+
163
+ transport.emitInbound(
164
+ JSON.stringify({
165
+ version: "2",
166
+ event: "message.send",
167
+ trace_id: "ti",
168
+ emitted_at: Date.now(),
169
+ to: { id: "u", type: "direct" },
170
+ sender: { sender_id: "user-1", type: "direct", display_name: "User" },
171
+ payload: {
172
+ message_id: "m-with-image",
173
+ message_mode: "normal",
174
+ message: {
175
+ body: {
176
+ fragments: [
177
+ { kind: "text", text: "see this:" },
178
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
179
+ ],
180
+ },
181
+ context: { mentions: [], reply: null },
182
+ streaming: {
183
+ status: "static",
184
+ sequence: 0,
185
+ mutation_policy: "sealed",
186
+ started_at: null,
187
+ completed_at: null,
188
+ },
189
+ sender: { sender_id: "user-1", type: "direct", display_name: "User" },
190
+ },
191
+ },
192
+ }),
193
+ );
194
+ await new Promise((r) => setTimeout(r, 30));
195
+ abortController.abort();
196
+ await startPromise;
197
+
198
+ expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
199
+ expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
200
+ expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
201
+ });
202
+ });
203
+
204
+ describe("openclaw-clawchat runtime connect flow", () => {
205
+ it("completes connect through MockTransport handshake", async () => {
206
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
207
+ const transport = new MockTransport();
208
+ const abortController = new AbortController();
209
+ const setStatus = vi.fn();
210
+ const getStatus = vi.fn(() => ({ accountId: "default" }));
211
+
212
+ // Provide a stub PluginRuntime so getOpenclawClawlingRuntime() resolves inside the gateway.
213
+ setOpenclawClawlingRuntime({ channel: undefined } as unknown as PluginRuntime);
214
+
215
+ const startPromise = startOpenclawClawlingGateway({
216
+ cfg: {} as OpenClawConfig,
217
+ account: {
218
+ accountId: "default",
219
+ name: "openclaw-clawchat",
220
+ enabled: true,
221
+ configured: true,
222
+ websocketUrl: "ws://t",
223
+ baseUrl: "",
224
+ token: "tk",
225
+ userId: "agent-1",
226
+ replyMode: "static",
227
+ forwardThinking: true,
228
+ forwardToolCalls: false,
229
+ allowFrom: [],
230
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
231
+ reconnect: {
232
+ initialDelay: 1000,
233
+ maxDelay: 30000,
234
+ jitterRatio: 0.3,
235
+ maxRetries: Number.POSITIVE_INFINITY,
236
+ },
237
+ heartbeat: { interval: 25000, timeout: 10000 },
238
+ ack: { timeout: 10000, autoResendOnTimeout: false },
239
+ },
240
+ abortSignal: abortController.signal,
241
+ setStatus,
242
+ getStatus,
243
+ log: { info: () => {}, error: () => {} },
244
+ transport,
245
+ });
246
+
247
+ // Give event loop one tick so connect() subscribes, then authenticate.
248
+ await new Promise((r) => setTimeout(r, 0));
249
+ transport.emitInbound(
250
+ JSON.stringify({
251
+ version: "2",
252
+ event: "connect.challenge",
253
+ trace_id: "t1",
254
+ emitted_at: Date.now(),
255
+ payload: { nonce: "n1" },
256
+ }),
257
+ );
258
+ transport.emitInbound(
259
+ JSON.stringify({
260
+ version: "2",
261
+ event: "hello-ok",
262
+ trace_id: "t2",
263
+ emitted_at: Date.now(),
264
+ payload: {},
265
+ }),
266
+ );
267
+ // Let the state propagate, then abort so startOpenclawClawlingGateway resolves.
268
+ await new Promise((r) => setTimeout(r, 10));
269
+ abortController.abort();
270
+ await startPromise;
271
+
272
+ expect(setStatus).toHaveBeenCalledWith(
273
+ expect.objectContaining({ connected: true, running: true }),
274
+ );
275
+ });
276
+ });
package/src/runtime.ts ADDED
@@ -0,0 +1,316 @@
1
+ import {
2
+ AckTimeoutError,
3
+ AuthError,
4
+ ProtocolError,
5
+ StateError,
6
+ TransportError,
7
+ type ClawlingChatClient,
8
+ type DownlinkMessageSendPayload,
9
+ type Envelope,
10
+ type Transport,
11
+ } from "@newbase-clawchat/sdk";
12
+ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
13
+ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
14
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
+ import { createOpenclawClawlingClient } from "./client.ts";
17
+ import { CHANNEL_ID, type ResolvedOpenclawClawlingAccount } from "./config.ts";
18
+ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
19
+ import { fetchInboundMedia } from "./media-runtime.ts";
20
+ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
21
+ import { sendStreamingText } from "./streaming.ts";
22
+
23
+ type Log = { info?: (m: string) => void; error?: (m: string) => void };
24
+
25
+ const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } =
26
+ createPluginRuntimeStore<PluginRuntime>("openclaw-clawchat runtime not initialized");
27
+
28
+ export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
29
+
30
+ const activeClients = new Map<string, ClawlingChatClient>();
31
+
32
+ export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient | undefined {
33
+ return activeClients.get(accountId);
34
+ }
35
+
36
+ export type ClawlingState =
37
+ | "idle"
38
+ | "connecting"
39
+ | "challenging"
40
+ | "authenticating"
41
+ | "connected"
42
+ | "reconnecting"
43
+ | "disconnected";
44
+
45
+ export function mapClawlingStateToStatus(state: ClawlingState): {
46
+ connected: boolean;
47
+ running: boolean;
48
+ lastStartAt?: number;
49
+ lastStopAt?: number;
50
+ } {
51
+ const now = Date.now();
52
+ switch (state) {
53
+ case "connected":
54
+ return { connected: true, running: true, lastStartAt: now };
55
+ case "reconnecting":
56
+ return { connected: false, running: true };
57
+ case "disconnected":
58
+ return { connected: false, running: false, lastStopAt: now };
59
+ default:
60
+ return { connected: false, running: true };
61
+ }
62
+ }
63
+
64
+ export function classifyClawlingClientError(err: unknown): {
65
+ kind: "auth" | "transport" | "protocol" | "ack-timeout" | "state" | "unknown";
66
+ retry: boolean;
67
+ message: string;
68
+ } {
69
+ if (err instanceof AuthError) return { kind: "auth", retry: false, message: err.message };
70
+ if (err instanceof TransportError)
71
+ return { kind: "transport", retry: true, message: err.message };
72
+ if (err instanceof AckTimeoutError)
73
+ return { kind: "ack-timeout", retry: false, message: err.message };
74
+ if (err instanceof ProtocolError) return { kind: "protocol", retry: false, message: err.message };
75
+ if (err instanceof StateError) return { kind: "state", retry: false, message: err.message };
76
+ return {
77
+ kind: "unknown",
78
+ retry: false,
79
+ message: err instanceof Error ? err.message : String(err),
80
+ };
81
+ }
82
+
83
+ export interface StartGatewayParams {
84
+ cfg: OpenClawConfig;
85
+ account: ResolvedOpenclawClawlingAccount;
86
+ abortSignal: AbortSignal;
87
+ setStatus: (next: ChannelAccountSnapshot) => void;
88
+ getStatus: () => ChannelAccountSnapshot;
89
+ log?: Log;
90
+ /** Test hook only. */
91
+ transport?: Transport;
92
+ }
93
+
94
+ export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
95
+ const { cfg, account, abortSignal, setStatus, getStatus, log } = params;
96
+ // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
97
+ const runtime = getOpenclawClawlingRuntime();
98
+ const accountId = account.accountId;
99
+
100
+ const client = createOpenclawClawlingClient(account, {
101
+ ...(params.transport ? { transport: params.transport } : {}),
102
+ });
103
+
104
+ client.on("state", ({ from, to }) => {
105
+ log?.info?.(`[${accountId}] openclaw-clawchat state ${from} -> ${to}`);
106
+ const next = { ...getStatus(), ...mapClawlingStateToStatus(to as ClawlingState) };
107
+ setStatus(next);
108
+ });
109
+
110
+ client.on("error", (err: unknown) => {
111
+ const classified = classifyClawlingClientError(err);
112
+ if (classified.kind === "auth") {
113
+ log?.error?.(`[${accountId}] openclaw-clawchat auth failed: ${classified.message}`);
114
+ setStatus({
115
+ ...getStatus(),
116
+ connected: false,
117
+ configured: false,
118
+ running: false,
119
+ lastError: classified.message,
120
+ });
121
+ } else if (classified.kind === "transport") {
122
+ log?.info?.(
123
+ `[${accountId}] openclaw-clawchat transport error (reconnecting): ${classified.message}`,
124
+ );
125
+ setStatus({ ...getStatus(), connected: false, running: true });
126
+ } else if (classified.kind === "ack-timeout") {
127
+ log?.info?.(`[${accountId}] openclaw-clawchat ack timeout: ${classified.message}`);
128
+ } else if (classified.kind === "protocol") {
129
+ log?.error?.(`[${accountId}] openclaw-clawchat protocol error: ${classified.message}`);
130
+ } else if (classified.kind === "state") {
131
+ log?.info?.(`[${accountId}] openclaw-clawchat state error: ${classified.message}`);
132
+ } else {
133
+ log?.error?.(`[${accountId}] openclaw-clawchat sdk error: ${classified.message}`);
134
+ }
135
+ });
136
+
137
+ client.on("message", (env: Envelope) => {
138
+ dispatchOpenclawClawlingInbound({
139
+ envelope: env as Envelope<DownlinkMessageSendPayload>,
140
+ cfg,
141
+ runtime,
142
+ account,
143
+ log,
144
+ ingest: async (turn) => {
145
+ const rt = runtime.channel;
146
+ const storePath = rt.session.resolveStorePath(cfg.session?.store);
147
+ const route = rt.routing.resolveAgentRoute({
148
+ cfg,
149
+ channel: CHANNEL_ID,
150
+ accountId,
151
+ peer: turn.peer,
152
+ });
153
+ const body = rt.reply.formatAgentEnvelope({
154
+ channel: "Clawling Chat",
155
+ from: turn.senderNickName || turn.senderId,
156
+ body: turn.rawBody,
157
+ timestamp: turn.timestamp,
158
+ ...rt.reply.resolveEnvelopeFormatOptions(cfg),
159
+ });
160
+ const ctxPayload = rt.reply.finalizeInboundContext({
161
+ Body: body,
162
+ BodyForAgent: turn.rawBody,
163
+ RawBody: turn.rawBody,
164
+ CommandBody: turn.rawBody,
165
+ From: `${CHANNEL_ID}:${turn.senderId}`,
166
+ To: `${CHANNEL_ID}:${account.userId}`,
167
+ SessionKey: route.sessionKey,
168
+ AccountId: route.accountId ?? accountId,
169
+ ChatType: turn.peer.kind,
170
+ ConversationLabel: turn.senderNickName || turn.senderId,
171
+ SenderId: turn.senderId,
172
+ Provider: CHANNEL_ID,
173
+ Surface: CHANNEL_ID,
174
+ MessageSid: turn.messageId,
175
+ MessageSidFull: turn.messageId,
176
+ Timestamp: turn.timestamp,
177
+ OriginatingChannel: CHANNEL_ID,
178
+ OriginatingTo: `${CHANNEL_ID}:${account.userId}`,
179
+ });
180
+ // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
181
+ const inboundPaths =
182
+ turn.mediaItems.length > 0
183
+ ? await fetchInboundMedia(turn.mediaItems, {
184
+ runtime,
185
+ log,
186
+ maxBytes: 20 * 1024 * 1024,
187
+ })
188
+ : [];
189
+ if (inboundPaths.length > 0) {
190
+ (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
191
+ (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
192
+ }
193
+
194
+ try {
195
+ await rt.session.recordInboundSession({
196
+ storePath,
197
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
198
+ ctx: ctxPayload,
199
+ onRecordError: (err) => {
200
+ log?.error?.(
201
+ `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
202
+ );
203
+ },
204
+ });
205
+ } catch (err) {
206
+ log?.error?.(
207
+ `[${accountId}] openclaw-clawchat failed to record inbound session: ${String(err)}`,
208
+ );
209
+ }
210
+
211
+ const replyCtx = turn.replyCtx;
212
+ const { dispatcher, replyOptions, markDispatchIdle } =
213
+ createOpenclawClawlingReplyDispatcher({
214
+ cfg,
215
+ runtime,
216
+ account,
217
+ client,
218
+ target: { chatId: turn.peer.id, chatType: turn.peer.kind },
219
+ ...(replyCtx ? { replyCtx } : {}),
220
+ inboundMessageId: turn.messageId,
221
+ inboundForFinalReply: {
222
+ senderId: turn.senderId,
223
+ senderNickName: turn.senderNickName || turn.senderId,
224
+ bodyText: turn.rawBody,
225
+ },
226
+ log,
227
+ });
228
+
229
+ const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
230
+ log?.info?.(
231
+ `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
232
+ );
233
+ try {
234
+ const dispatchResult = await rt.reply.withReplyDispatcher({
235
+ dispatcher,
236
+ run: () =>
237
+ rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
238
+ });
239
+ const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
240
+ const queuedFinal = Boolean(
241
+ (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
242
+ );
243
+ log?.info?.(
244
+ `[${accountId}] openclaw-clawchat dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
245
+ );
246
+ if (!queuedFinal && Object.values(counts).every((n) => !n)) {
247
+ log?.info?.(
248
+ `[${accountId}] openclaw-clawchat NO reply was produced (no final / block / tool dispatched). ` +
249
+ `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
250
+ `or send-policy denied; or a plugin claimed the binding.`,
251
+ );
252
+ }
253
+ } catch (err) {
254
+ log?.error?.(
255
+ `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
256
+ );
257
+ throw err;
258
+ } finally {
259
+ markDispatchIdle();
260
+ }
261
+ },
262
+ }).catch((err) => {
263
+ log?.error?.(
264
+ `[${accountId}] openclaw-clawchat inbound dispatch error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
265
+ );
266
+ });
267
+ });
268
+
269
+ // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
270
+ // (auth). Transport failures (server unreachable, DNS error, etc.) do
271
+ // NOT reject this promise — the SDK catches them internally and drives
272
+ // its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
273
+ // capped at `maxDelay`, with jitter). So we never throw here on anything
274
+ // other than auth failure; on auth we tear the account down cleanly and
275
+ // return without throwing (which would make the gateway supervisor
276
+ // restart us immediately in a tight loop).
277
+ try {
278
+ await client.connect();
279
+ } catch (err) {
280
+ const classified = classifyClawlingClientError(err);
281
+ setStatus({
282
+ ...getStatus(),
283
+ connected: false,
284
+ configured: classified.kind !== "auth",
285
+ running: false,
286
+ lastError: classified.message,
287
+ });
288
+ log?.error?.(
289
+ `[${accountId}] openclaw-clawchat connect failed (${classified.kind}): ${classified.message}`,
290
+ );
291
+ return;
292
+ }
293
+ activeClients.set(accountId, client);
294
+ setStatus({
295
+ ...getStatus(),
296
+ connected: true,
297
+ running: true,
298
+ lastStartAt: Date.now(),
299
+ });
300
+ log?.info?.(`[${accountId}] openclaw-clawchat connected`);
301
+
302
+ await waitUntilAbort(abortSignal, async () => {
303
+ activeClients.delete(accountId);
304
+ client.close();
305
+ setStatus({
306
+ ...getStatus(),
307
+ connected: false,
308
+ running: false,
309
+ lastStopAt: Date.now(),
310
+ });
311
+ log?.info?.(`[${accountId}] openclaw-clawchat disconnected`);
312
+ });
313
+ }
314
+
315
+ // Re-export so channel.ts outbound.sendText can use the streaming helper when needed.
316
+ export { sendStreamingText };