@efengx/openclaw-channel-dragon 0.5.30 → 0.5.32

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.
@@ -5,17 +5,30 @@ export declare class SseComponent implements IComponent {
5
5
  private http;
6
6
  private channel;
7
7
  private options;
8
- private eventSource;
9
8
  private shouldReconnect;
10
9
  private reconnectTimer;
10
+ private idleTimer;
11
+ private fetchAbort;
12
+ private reader;
13
+ private reconnectAttempts;
14
+ private connectionSeq;
15
+ private lastTransportActivityAt;
11
16
  constructor(http: HttpComponent, channel: ChannelComponent, options: {
12
17
  agentId: string;
18
+ accountId: string;
13
19
  version: string;
14
20
  logger?: any;
21
+ abortSignal?: AbortSignal;
22
+ setStatus?: (next: Record<string, unknown>) => void;
23
+ reconnectMs?: number;
24
+ idleTimeoutMs?: number;
15
25
  });
16
26
  start(): Promise<void>;
17
27
  stop(): Promise<void>;
18
28
  private connect;
29
+ private touchTransport;
30
+ private armIdleWatchdog;
31
+ private markDisconnected;
19
32
  private startSseReader;
20
33
  private handleEvent;
21
34
  private scheduleReconnect;
@@ -2,44 +2,117 @@ export class SseComponent {
2
2
  http;
3
3
  channel;
4
4
  options;
5
- eventSource;
6
5
  shouldReconnect = true;
7
6
  reconnectTimer;
7
+ idleTimer;
8
+ fetchAbort;
9
+ reader;
10
+ reconnectAttempts = 0;
11
+ connectionSeq = 0;
12
+ lastTransportActivityAt = 0;
8
13
  constructor(http, channel, options) {
9
14
  this.http = http;
10
15
  this.channel = channel;
11
16
  this.options = options;
12
17
  }
13
18
  async start() {
19
+ this.options.logger?.info?.(`[dragon channels][SSE] component start: version=${this.options.version}, agent=${this.options.agentId}, account=${this.options.accountId}, baseUrl=${this.http.options.baseURL}`);
20
+ this.options.setStatus?.({
21
+ accountId: this.options.accountId,
22
+ connected: false,
23
+ lastError: null,
24
+ });
25
+ this.options.abortSignal?.addEventListener('abort', () => {
26
+ this.options.logger?.info?.(`[dragon channels][SSE] upstream abort received: agent=${this.options.agentId}, account=${this.options.accountId}`);
27
+ void this.stop();
28
+ }, { once: true });
14
29
  this.connect();
15
30
  }
16
31
  async stop() {
17
32
  this.shouldReconnect = false;
18
33
  if (this.reconnectTimer)
19
34
  clearTimeout(this.reconnectTimer);
20
- this.eventSource?.close();
35
+ this.reconnectTimer = undefined;
36
+ if (this.idleTimer)
37
+ clearTimeout(this.idleTimer);
38
+ this.idleTimer = undefined;
39
+ this.fetchAbort?.abort();
40
+ this.fetchAbort = undefined;
41
+ try {
42
+ await this.reader?.cancel?.();
43
+ }
44
+ catch {
45
+ // Reader cancellation can race with fetch abort.
46
+ }
47
+ this.reader = undefined;
48
+ this.options.setStatus?.({
49
+ accountId: this.options.accountId,
50
+ connected: false,
51
+ lastStopAt: Date.now(),
52
+ });
53
+ this.options.logger?.info?.(`[dragon channels][SSE] component stopped: agent=${this.options.agentId}, account=${this.options.accountId}`);
21
54
  }
22
55
  connect() {
23
56
  if (!this.shouldReconnect)
24
57
  return;
25
58
  const { agentId, logger } = this.options;
26
59
  const url = `${this.http.options.baseURL}/api/agents/events`;
27
- logger?.info?.(`[dragon channels][Dragon Plugin] v${this.options.version} connecting to Orchestrator SSE: ${url} (agent=${agentId})`);
60
+ const connectionId = `${Date.now()}-${++this.connectionSeq}`;
61
+ logger?.info?.(`[dragon channels][SSE] connect start: connection=${connectionId}, attempt=${this.reconnectAttempts + 1}, version=${this.options.version}, url=${url}, agent=${agentId}, account=${this.options.accountId}`);
28
62
  try {
29
- // In Node 22, we might need a fetch-based SSE or a library,
30
- // but for simplicity we'll use the same pattern as Bridge if possible.
31
- // Actually, standard EventSource is not in Node globals yet.
32
- // We'll use a simple fetch-based stream reader.
33
- this.startSseReader(url);
63
+ void this.startSseReader(url, connectionId);
34
64
  }
35
65
  catch (err) {
36
- logger?.error?.(`[dragon channels][Dragon Plugin] SSE Init Failed: ${err.message}`);
37
- this.scheduleReconnect();
66
+ logger?.error?.(`[dragon channels][SSE] connect init failed: connection=${connectionId}, error=${err.message}`);
67
+ this.markDisconnected(err?.message || String(err));
68
+ this.scheduleReconnect('init_failed');
69
+ }
70
+ }
71
+ touchTransport(connectionId, reason) {
72
+ const now = Date.now();
73
+ this.lastTransportActivityAt = now;
74
+ this.options.setStatus?.({
75
+ accountId: this.options.accountId,
76
+ connected: true,
77
+ lastEventAt: now,
78
+ lastTransportActivityAt: now,
79
+ lastError: null,
80
+ });
81
+ this.options.logger?.debug?.(`[dragon channels][SSE] transport activity: connection=${connectionId}, reason=${reason}, at=${now}`);
82
+ this.armIdleWatchdog(connectionId);
83
+ }
84
+ armIdleWatchdog(connectionId) {
85
+ if (this.idleTimer)
86
+ clearTimeout(this.idleTimer);
87
+ const timeoutMs = this.options.idleTimeoutMs ?? 90_000;
88
+ this.idleTimer = setTimeout(() => {
89
+ if (!this.shouldReconnect)
90
+ return;
91
+ const idleMs = Date.now() - this.lastTransportActivityAt;
92
+ this.options.logger?.warn?.(`[dragon channels][SSE] idle timeout: connection=${connectionId}, idleMs=${idleMs}, timeoutMs=${timeoutMs}; aborting fetch for reconnect`);
93
+ this.markDisconnected(`SSE idle timeout after ${idleMs}ms`);
94
+ this.fetchAbort?.abort();
95
+ }, timeoutMs);
96
+ if (typeof this.idleTimer === 'object' && 'unref' in this.idleTimer) {
97
+ this.idleTimer.unref();
38
98
  }
39
99
  }
40
- async startSseReader(url) {
100
+ markDisconnected(error) {
101
+ this.options.setStatus?.({
102
+ accountId: this.options.accountId,
103
+ connected: false,
104
+ lastError: error,
105
+ lastTransportActivityAt: this.lastTransportActivityAt || null,
106
+ });
107
+ }
108
+ async startSseReader(url, connectionId) {
109
+ const abort = new AbortController();
110
+ this.fetchAbort = abort;
111
+ const upstreamAbort = () => abort.abort();
112
+ this.options.abortSignal?.addEventListener('abort', upstreamAbort, { once: true });
41
113
  try {
42
114
  const response = await fetch(url, {
115
+ signal: abort.signal,
43
116
  headers: {
44
117
  'Accept': 'text/event-stream',
45
118
  'Authorization': `Bearer ${this.http.options.authToken || ''}`
@@ -51,38 +124,87 @@ export class SseComponent {
51
124
  const reader = response.body?.getReader();
52
125
  if (!reader)
53
126
  throw new Error("Response body is null");
54
- this.options.logger?.debug?.(`[dragon channels][Dragon Plugin] SSE Stream Established.`);
127
+ this.reader = reader;
128
+ this.reconnectAttempts = 0;
129
+ const now = Date.now();
130
+ this.lastTransportActivityAt = now;
131
+ this.options.setStatus?.({
132
+ accountId: this.options.accountId,
133
+ connected: true,
134
+ lastConnectedAt: now,
135
+ lastTransportActivityAt: now,
136
+ lastError: null,
137
+ reconnectAttempts: 0,
138
+ });
139
+ this.options.logger?.info?.(`[dragon channels][SSE] stream established: connection=${connectionId}, agent=${this.options.agentId}, account=${this.options.accountId}`);
140
+ this.armIdleWatchdog(connectionId);
55
141
  const decoder = new TextDecoder();
56
142
  let buffer = "";
57
143
  while (this.shouldReconnect) {
58
144
  const { value, done } = await reader.read();
59
- if (done)
145
+ if (done) {
146
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream ended by remote: connection=${connectionId}`);
60
147
  break;
148
+ }
149
+ this.touchTransport(connectionId, 'chunk');
61
150
  buffer += decoder.decode(value, { stream: true });
62
151
  const lines = buffer.split("\n");
63
152
  buffer = lines.pop() || "";
64
153
  for (const line of lines) {
65
154
  const cleanLine = line.trim();
155
+ if (!cleanLine || cleanLine.startsWith(':')) {
156
+ this.touchTransport(connectionId, cleanLine.startsWith(':') ? 'heartbeat' : 'blank_line');
157
+ continue;
158
+ }
66
159
  if (cleanLine.startsWith("data: ")) {
67
160
  try {
68
161
  const data = JSON.parse(cleanLine.slice(6));
162
+ this.options.logger?.debug?.(`[dragon channels][SSE] event received: connection=${connectionId}, type=${data?.type || 'unknown'}, agent=${data?.agentId || 'unknown'}`);
69
163
  void this.handleEvent(data).catch((err) => {
70
- this.options.logger?.error?.(`[dragon channels][Dragon Plugin] SSE event handling failed: ${err?.message || err}`);
164
+ this.options.logger?.error?.(`[dragon channels][SSE] event handling failed: connection=${connectionId}, error=${err?.message || err}`);
71
165
  });
72
166
  }
73
167
  catch (e) {
74
- this.options.logger?.error?.(`[dragon channels][Dragon Plugin] SSE event JSON parse failed for line: "${cleanLine}", error: ${e?.message || e}`);
168
+ this.options.logger?.error?.(`[dragon channels][SSE] event JSON parse failed: connection=${connectionId}, line="${cleanLine}", error=${e?.message || e}`);
75
169
  }
76
170
  }
77
171
  }
78
172
  }
173
+ if (this.shouldReconnect) {
174
+ const message = 'SSE stream ended';
175
+ this.markDisconnected(message);
176
+ this.scheduleReconnect(message);
177
+ }
79
178
  }
80
179
  catch (err) {
81
180
  if (this.shouldReconnect) {
82
- this.options.logger?.warn?.(`[dragon channels][Dragon Plugin] SSE Stream Disconnected: ${err.message}`);
83
- this.scheduleReconnect();
181
+ const message = err?.name === 'AbortError'
182
+ ? 'SSE fetch aborted'
183
+ : (err?.message || String(err));
184
+ this.options.logger?.warn?.(`[dragon channels][SSE] stream disconnected: connection=${connectionId}, error=${message}`);
185
+ this.markDisconnected(message);
186
+ this.scheduleReconnect(message);
84
187
  }
85
188
  }
189
+ finally {
190
+ this.options.abortSignal?.removeEventListener('abort', upstreamAbort);
191
+ if (this.reader) {
192
+ try {
193
+ await this.reader.cancel?.();
194
+ }
195
+ catch {
196
+ // Ignore cancellation races.
197
+ }
198
+ }
199
+ if (this.fetchAbort === abort) {
200
+ this.fetchAbort = undefined;
201
+ }
202
+ if (this.idleTimer) {
203
+ clearTimeout(this.idleTimer);
204
+ this.idleTimer = undefined;
205
+ }
206
+ this.reader = undefined;
207
+ }
86
208
  }
87
209
  async handleEvent(data) {
88
210
  const { agentId, logger } = this.options;
@@ -101,9 +223,33 @@ export class SseComponent {
101
223
  // Handle history sync if needed, but deliverToOpenClaw usually handles normal chat
102
224
  }
103
225
  }
104
- scheduleReconnect() {
226
+ scheduleReconnect(reason) {
227
+ if (!this.shouldReconnect)
228
+ return;
105
229
  if (this.reconnectTimer)
106
230
  clearTimeout(this.reconnectTimer);
107
- this.reconnectTimer = setTimeout(() => this.connect(), 5000);
231
+ this.reconnectAttempts += 1;
232
+ const baseMs = this.options.reconnectMs ?? 5_000;
233
+ const delayMs = Math.min(60_000, baseMs * Math.max(1, this.reconnectAttempts));
234
+ this.options.setStatus?.({
235
+ accountId: this.options.accountId,
236
+ connected: false,
237
+ restartPending: true,
238
+ reconnectAttempts: this.reconnectAttempts,
239
+ lastError: reason,
240
+ });
241
+ this.options.logger?.info?.(`[dragon channels][SSE] reconnect scheduled: attempt=${this.reconnectAttempts}, delayMs=${delayMs}, reason=${reason}, agent=${this.options.agentId}, account=${this.options.accountId}`);
242
+ this.reconnectTimer = setTimeout(() => {
243
+ this.reconnectTimer = undefined;
244
+ this.options.setStatus?.({
245
+ accountId: this.options.accountId,
246
+ restartPending: false,
247
+ reconnectAttempts: this.reconnectAttempts,
248
+ });
249
+ this.connect();
250
+ }, delayMs);
251
+ if (typeof this.reconnectTimer === 'object' && 'unref' in this.reconnectTimer) {
252
+ this.reconnectTimer.unref();
253
+ }
108
254
  }
109
255
  }
package/dist/index.js CHANGED
@@ -22,10 +22,16 @@ function resolveAccountIdFromSessionKey(sessionKey) {
22
22
  }
23
23
  return "default";
24
24
  }
25
+ function containerKey(account) {
26
+ return `${account.accountId}:${account.agentId}:${account.orchestratorUrl}`;
27
+ }
25
28
  async function getOrCreateContainer(account, ctx) {
26
- const key = `${account.agentId}:${account.orchestratorUrl}`;
27
- if (containers.has(key))
29
+ const key = containerKey(account);
30
+ if (containers.has(key)) {
31
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
32
+ logger?.debug?.(`[dragon channels][Dragon Plugin] Reusing channel container: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
28
33
  return containers.get(key);
34
+ }
29
35
  const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
30
36
  const container = new ServiceContainer();
31
37
  logger?.info?.(`[dragon channels][Dragon Plugin] Starting channel plugin v${dragonChannelPluginVersion} for agent=${account.agentId}, account=${account.accountId}, orchestrator=${account.orchestratorUrl}`);
@@ -51,13 +57,43 @@ async function getOrCreateContainer(account, ctx) {
51
57
  // 3. Sync Infrastructure
52
58
  container.register('sse', new SseComponent(http, channel, {
53
59
  agentId: account.agentId,
60
+ accountId: account.accountId,
54
61
  version: dragonChannelPluginVersion,
55
- logger
62
+ logger,
63
+ abortSignal: ctx.abortSignal,
64
+ setStatus: ctx.setStatus,
65
+ reconnectMs: 5_000,
66
+ idleTimeoutMs: 90_000,
56
67
  }));
57
68
  await container.startAll();
58
69
  containers.set(key, container);
70
+ logger?.info?.(`[dragon channels][Dragon Plugin] Channel container started: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
59
71
  return container;
60
72
  }
73
+ async function stopContainer(account, ctx) {
74
+ const key = containerKey(account);
75
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
76
+ const container = containers.get(key);
77
+ if (!container) {
78
+ logger?.debug?.(`[dragon channels][Dragon Plugin] No channel container to stop: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
79
+ return;
80
+ }
81
+ logger?.info?.(`[dragon channels][Dragon Plugin] Stopping channel container: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
82
+ try {
83
+ await container.stopAll();
84
+ }
85
+ finally {
86
+ containers.delete(key);
87
+ ctx?.setStatus?.({
88
+ accountId: account.accountId,
89
+ connected: false,
90
+ running: false,
91
+ restartPending: false,
92
+ lastStopAt: Date.now(),
93
+ });
94
+ logger?.info?.(`[dragon channels][Dragon Plugin] Channel container stopped: key=${key}, agent=${account.agentId}, account=${account.accountId}`);
95
+ }
96
+ }
61
97
  const base = createChannelPluginBase({
62
98
  id: channelId,
63
99
  meta: {
@@ -80,7 +116,7 @@ const base = createChannelPluginBase({
80
116
  return {
81
117
  accountId,
82
118
  agentId: accountConfig.agentId || accountId,
83
- orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
119
+ orchestratorUrl: accountConfig.orchestratorUrl,
84
120
  orchestratorAuthToken: accountConfig.orchestratorAuthToken || accountConfig.authToken,
85
121
  };
86
122
  },
@@ -93,13 +129,35 @@ const plugin = createChatChannelPlugin({
93
129
  gateway: {
94
130
  startAccount: async (ctx) => {
95
131
  const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
96
- await getOrCreateContainer(account, ctx);
97
- // v0.5.6+: Prevent channel exit by waiting for abort signal
98
- if (ctx.abortSignal.aborted)
99
- return;
100
- await new Promise((resolve) => {
101
- ctx.abortSignal.addEventListener('abort', resolve, { once: true });
132
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
133
+ logger?.info?.(`[dragon channels][Dragon Plugin] startAccount begin: agent=${account.agentId}, account=${account.accountId}, orchestrator=${account.orchestratorUrl}`);
134
+ ctx.setStatus?.({
135
+ accountId: account.accountId,
136
+ enabled: true,
137
+ configured: true,
138
+ running: true,
139
+ connected: false,
140
+ lastStartAt: Date.now(),
141
+ lastError: null,
102
142
  });
143
+ try {
144
+ await getOrCreateContainer(account, ctx);
145
+ if (ctx.abortSignal.aborted)
146
+ return;
147
+ await new Promise((resolve) => {
148
+ ctx.abortSignal.addEventListener('abort', resolve, { once: true });
149
+ });
150
+ }
151
+ finally {
152
+ logger?.info?.(`[dragon channels][Dragon Plugin] startAccount exiting: agent=${account.agentId}, account=${account.accountId}, aborted=${ctx.abortSignal.aborted}`);
153
+ await stopContainer(account, ctx);
154
+ }
155
+ },
156
+ stopAccount: async (ctx) => {
157
+ const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
158
+ const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
159
+ logger?.info?.(`[dragon channels][Dragon Plugin] stopAccount requested: agent=${account.agentId}, account=${account.accountId}`);
160
+ await stopContainer(account, ctx);
103
161
  }
104
162
  },
105
163
  messaging: {
@@ -272,12 +330,12 @@ const entry = defineChannelPluginEntry({
272
330
  return;
273
331
  const accountId = resolveAccountIdFromSessionKey(sessionKey);
274
332
  const account = base.config.resolveAccount(api?.runtime?.cfg, accountId);
275
- const container = await getOrCreateContainer(account, {
276
- cfg: api?.runtime?.cfg,
277
- abortSignal: new AbortController().signal,
278
- channelRuntime: api?.runtime?.channelRuntime,
279
- logger: api?.runtime?.logger ?? api?.runtime?.log,
280
- });
333
+ const logger = api?.runtime?.logger ?? api?.runtime?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
334
+ const container = containers.get(containerKey(account));
335
+ if (!container) {
336
+ logger?.warn?.(`[dragon channels][Dragon Plugin] agent event skipped because managed container is not running: sessionKey=${sessionKey}, account=${account.accountId}, agent=${account.agentId}`);
337
+ return;
338
+ }
281
339
  const channel = container.get('channel');
282
340
  await channel.handleAgentEvent(evt);
283
341
  });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const dragonChannelPluginVersion = "0.5.30";
1
+ export declare const dragonChannelPluginVersion = "0.5.32";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const dragonChannelPluginVersion = "0.5.30";
1
+ export const dragonChannelPluginVersion = "0.5.32";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.5.30",
3
+ "version": "0.5.32",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",