@ynhcj/xiaoyi-channel 0.0.39-beta → 0.0.41-beta

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.
@@ -1,45 +1,34 @@
1
- // Dual WebSocket connection manager
2
- // References xiaoyi_v2/websocket.ts for dual connection pattern
1
+ // WebSocket connection manager (Single connection)
3
2
  import WebSocket from "ws";
4
3
  import { EventEmitter } from "events";
5
4
  import { HeartbeatManager } from "./heartbeat.js";
6
- import { sessionManager } from "./utils/session.js";
7
5
  /**
8
- * Manages dual WebSocket connections to XY servers.
9
- * Implements session-to-server binding for message routing.
6
+ * Manages single WebSocket connection to XY server.
10
7
  *
11
8
  * Events:
12
- * - 'message': (message: A2AJsonRpcRequest, sessionId: string, serverId: ServerIdentifier) => void
9
+ * - 'message': (message: A2AJsonRpcRequest, sessionId: string) => void
13
10
  * - 'data-event': (event: A2ADataEvent) => void
14
11
  * - 'gui-agent-response': (event: any) => void
15
- * - 'connected': (serverId: ServerIdentifier) => void
16
- * - 'disconnected': (serverId: ServerIdentifier) => void
17
- * - 'error': (error: Error, serverId: ServerIdentifier) => void
18
- * - 'ready': (serverId: ServerIdentifier) => void
12
+ * - 'trigger-event': (event: any) => void
13
+ * - 'connected': () => void
14
+ * - 'disconnected': () => void
15
+ * - 'error': (error: Error) => void
16
+ * - 'ready': () => void
19
17
  */
20
18
  export class XYWebSocketManager extends EventEmitter {
21
19
  config;
22
20
  runtime;
23
- ws1 = null;
24
- ws2 = null;
25
- state1 = {
21
+ ws = null;
22
+ state = {
26
23
  connected: false,
27
24
  ready: false,
28
25
  lastHeartbeat: 0,
29
26
  reconnectAttempts: 0,
30
27
  };
31
- state2 = {
32
- connected: false,
33
- ready: false,
34
- lastHeartbeat: 0,
35
- reconnectAttempts: 0,
36
- };
37
- heartbeat1 = null;
38
- heartbeat2 = null;
39
- reconnectTimer1 = null;
40
- reconnectTimer2 = null;
28
+ heartbeat = null;
29
+ reconnectTimer = null;
41
30
  isShuttingDown = false;
42
- // Logging functions following feishu pattern
31
+ // Logging functions
43
32
  log;
44
33
  error;
45
34
  // Health event callback
@@ -63,104 +52,66 @@ export class XYWebSocketManager extends EventEmitter {
63
52
  isConfigMatch(config) {
64
53
  return (this.config.apiKey === config.apiKey &&
65
54
  this.config.agentId === config.agentId &&
66
- this.config.wsUrl1 === config.wsUrl1 &&
67
- this.config.wsUrl2 === config.wsUrl2);
55
+ this.config.wsUrl === config.wsUrl);
68
56
  }
69
57
  /**
70
- * Connect to both WebSocket servers.
58
+ * Connect to WebSocket server.
71
59
  * Does not throw error if connection fails - logs warning instead.
72
60
  */
73
61
  async connect() {
74
- this.log("Connecting to XY WebSocket servers...");
62
+ this.log("Connecting to XY WebSocket server...");
75
63
  this.isShuttingDown = false;
76
- // Try to connect to both servers, but don't fail if both fail
77
- const results = await Promise.allSettled([
78
- this.connectServer("server1", this.config.wsUrl1),
79
- this.connectServer("server2", this.config.wsUrl2),
80
- ]);
81
- const successCount = results.filter((r) => r.status === "fulfilled").length;
82
- const failCount = results.filter((r) => r.status === "rejected").length;
83
- if (successCount > 0) {
84
- this.log(`Connected to ${successCount}/2 XY WebSocket servers`);
64
+ // Prevent re-entry: check if already connected or connecting
65
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
66
+ this.log("Already connected or connecting, skipping duplicate connect()");
67
+ return;
85
68
  }
86
- else {
87
- this.error(`Failed to connect to any WebSocket server (${failCount} failures). Plugin will continue but cannot receive messages.`);
88
- // Log individual failures
89
- results.forEach((result, index) => {
90
- if (result.status === "rejected") {
91
- this.error(` - Server ${index + 1} failed: ${result.reason.message}`);
92
- }
93
- });
69
+ try {
70
+ await this.connectServer(this.config.wsUrl);
71
+ this.log("Connected to XY WebSocket server");
72
+ }
73
+ catch (error) {
74
+ this.error(`Failed to connect to WebSocket server: ${error.message}`);
75
+ this.error("Plugin will continue but cannot receive messages.");
94
76
  }
95
77
  }
96
78
  /**
97
- * Disconnect from both WebSocket servers.
79
+ * Disconnect from WebSocket server.
98
80
  */
99
81
  disconnect() {
100
- this.log("Disconnecting from XY WebSocket servers...");
82
+ this.log("Disconnecting from XY WebSocket server...");
101
83
  this.isShuttingDown = true;
102
- if (this.reconnectTimer1) {
103
- clearTimeout(this.reconnectTimer1);
104
- this.reconnectTimer1 = null;
105
- }
106
- if (this.reconnectTimer2) {
107
- clearTimeout(this.reconnectTimer2);
108
- this.reconnectTimer2 = null;
84
+ if (this.reconnectTimer) {
85
+ clearTimeout(this.reconnectTimer);
86
+ this.reconnectTimer = null;
109
87
  }
110
- this.disconnectServer("server1");
111
- this.disconnectServer("server2");
112
- // Clear session bindings
113
- sessionManager.clear();
114
- this.log("Disconnected from XY WebSocket servers");
88
+ this.cleanupConnection();
89
+ this.log("Disconnected from XY WebSocket server");
115
90
  }
116
91
  /**
117
- * Send a message to the appropriate server based on session binding.
92
+ * Send a message to the server.
118
93
  */
119
94
  async sendMessage(sessionId, message) {
120
95
  console.log(`[WEBSOCKET-SEND] <<<<<<< Preparing to send message for session: ${sessionId} <<<<<<<`);
121
- // Determine which server to use
122
- let server = sessionManager.getBinding(sessionId);
123
- // If no binding, choose the first ready server
124
- if (!server) {
125
- if (this.state1.ready) {
126
- server = "server1";
127
- }
128
- else if (this.state2.ready) {
129
- server = "server2";
130
- }
131
- else {
132
- throw new Error("No ready WebSocket servers available");
133
- }
134
- console.log(`[WEBSOCKET-SEND] No binding found, selected: ${server}`);
135
- }
136
- else {
137
- console.log(`[WEBSOCKET-SEND] Using bound server: ${server}`);
138
- }
139
- // Send to the selected server
140
- const ws = server === "server1" ? this.ws1 : this.ws2;
141
- const state = server === "server1" ? this.state1 : this.state2;
142
- if (!ws || !state.ready || ws.readyState !== WebSocket.OPEN) {
143
- throw new Error(`WebSocket ${server} not ready`);
96
+ if (!this.ws || !this.state.ready || this.ws.readyState !== WebSocket.OPEN) {
97
+ throw new Error("WebSocket not ready");
144
98
  }
145
99
  const messageStr = JSON.stringify(message);
146
- // console.log(`[WS-${server}-SEND] Sending message frame:`, JSON.stringify(message, null, 2));
147
- ws.send(messageStr);
148
- console.log(`[WS-${server}-SEND] Message sent successfully, size: ${messageStr.length} bytes`);
100
+ this.ws.send(messageStr);
101
+ console.log(`[WS-SEND] Message sent successfully, size: ${messageStr.length} bytes`);
149
102
  }
150
103
  /**
151
- * Check if at least one server is ready.
104
+ * Check if server is ready.
152
105
  */
153
106
  isReady() {
154
- return this.state1.ready || this.state2.ready;
107
+ return this.state.ready;
155
108
  }
156
109
  /**
157
110
  * Get detailed connection diagnostics for monitoring and debugging.
158
- * Helps identify orphan connections and connection leaks.
159
111
  */
160
112
  getConnectionDiagnostics() {
161
113
  const cacheKey = `${this.config.apiKey}-${this.config.agentId}`;
162
- const server1Diag = this.getServerDiagnostic("server1", this.ws1, this.state1, this.heartbeat1, this.reconnectTimer1);
163
- const server2Diag = this.getServerDiagnostic("server2", this.ws2, this.state2, this.heartbeat2, this.reconnectTimer2);
114
+ const connectionDiag = this.getConnectionDiagnostic();
164
115
  // Count total event listeners on the manager
165
116
  const totalEventListeners = this.listenerCount('message') +
166
117
  this.listenerCount('connected') +
@@ -171,21 +122,20 @@ export class XYWebSocketManager extends EventEmitter {
171
122
  this.listenerCount('gui-agent-response');
172
123
  return {
173
124
  cacheKey,
174
- server1: server1Diag,
175
- server2: server2Diag,
125
+ connection: connectionDiag,
176
126
  isShuttingDown: this.isShuttingDown,
177
127
  totalEventListeners,
178
128
  };
179
129
  }
180
130
  /**
181
- * Get diagnostic info for a single server connection.
131
+ * Get diagnostic info for the connection.
182
132
  */
183
- getServerDiagnostic(serverId, ws, state, heartbeat, reconnectTimer) {
184
- const exists = ws !== null;
133
+ getConnectionDiagnostic() {
134
+ const exists = this.ws !== null;
185
135
  let readyState = 'NULL';
186
136
  let listenerCount = 0;
187
- if (ws) {
188
- switch (ws.readyState) {
137
+ if (this.ws) {
138
+ switch (this.ws.readyState) {
189
139
  case WebSocket.CONNECTING:
190
140
  readyState = 'CONNECTING';
191
141
  break;
@@ -200,34 +150,70 @@ export class XYWebSocketManager extends EventEmitter {
200
150
  break;
201
151
  }
202
152
  // Count event listeners on the WebSocket
203
- listenerCount = ws.listenerCount('message') +
204
- ws.listenerCount('close') +
205
- ws.listenerCount('error') +
206
- ws.listenerCount('open') +
207
- ws.listenerCount('pong');
153
+ listenerCount = this.ws.listenerCount('message') +
154
+ this.ws.listenerCount('close') +
155
+ this.ws.listenerCount('error') +
156
+ this.ws.listenerCount('open') +
157
+ this.ws.listenerCount('pong');
208
158
  }
209
159
  // Orphan detection: connection is OPEN but has no message listeners
210
160
  const isOrphan = exists &&
211
- ws.readyState === WebSocket.OPEN &&
212
- ws.listenerCount('message') === 0;
161
+ this.ws.readyState === WebSocket.OPEN &&
162
+ this.ws.listenerCount('message') === 0;
213
163
  return {
214
164
  exists,
215
165
  readyState,
216
- stateConnected: state.connected,
217
- stateReady: state.ready,
218
- reconnectAttempts: state.reconnectAttempts,
219
- lastHeartbeat: state.lastHeartbeat,
220
- heartbeatActive: heartbeat !== null,
221
- hasReconnectTimer: reconnectTimer !== null,
166
+ stateConnected: this.state.connected,
167
+ stateReady: this.state.ready,
168
+ reconnectAttempts: this.state.reconnectAttempts,
169
+ lastHeartbeat: this.state.lastHeartbeat,
170
+ heartbeatActive: this.heartbeat !== null,
171
+ hasReconnectTimer: this.reconnectTimer !== null,
222
172
  listenerCount,
223
173
  isOrphan,
224
174
  };
225
175
  }
226
176
  /**
227
- * Connect to a specific server.
177
+ * Clean up connection without triggering reconnection.
228
178
  */
229
- async connectServer(serverId, url) {
179
+ cleanupConnection() {
180
+ // Stop heartbeat
181
+ if (this.heartbeat) {
182
+ this.heartbeat.stop();
183
+ this.heartbeat = null;
184
+ }
185
+ // Clear reconnect timer
186
+ if (this.reconnectTimer) {
187
+ clearTimeout(this.reconnectTimer);
188
+ this.reconnectTimer = null;
189
+ }
190
+ // Clean up WebSocket
191
+ if (this.ws) {
192
+ // Remove all event listeners
193
+ this.ws.removeAllListeners();
194
+ // Close the connection if still open
195
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
196
+ try {
197
+ this.ws.close();
198
+ }
199
+ catch (err) {
200
+ this.error("Error closing WebSocket:", err);
201
+ }
202
+ }
203
+ // Clear reference
204
+ this.ws = null;
205
+ }
206
+ // Reset state
207
+ this.state.connected = false;
208
+ this.state.ready = false;
209
+ }
210
+ /**
211
+ * Connect to server.
212
+ */
213
+ async connectServer(url) {
230
214
  return new Promise((resolve, reject) => {
215
+ // ✅ Clean up old connection first
216
+ this.cleanupConnection();
231
217
  // Check if URL is wss with IP address to bypass certificate validation
232
218
  const urlObj = new URL(url);
233
219
  const isWssWithIP = urlObj.protocol === 'wss:' && /^(\d{1,3}\.){3}\d{1,3}$/.test(urlObj.hostname);
@@ -241,88 +227,49 @@ export class XYWebSocketManager extends EventEmitter {
241
227
  };
242
228
  // Bypass certificate validation for wss with IP address
243
229
  if (isWssWithIP) {
244
- this.log(`${serverId}: Bypassing certificate validation for IP address: ${urlObj.hostname}`);
230
+ this.log(`Bypassing certificate validation for IP address: ${urlObj.hostname}`);
245
231
  wsOptions.rejectUnauthorized = false;
246
232
  }
247
233
  const ws = new WebSocket(url, wsOptions);
248
- const state = serverId === "server1" ? this.state1 : this.state2;
249
- // Set the WebSocket instance
250
- if (serverId === "server1") {
251
- this.ws1 = ws;
252
- }
253
- else {
254
- this.ws2 = ws;
255
- }
234
+ this.ws = ws;
256
235
  // Connection timeout
257
236
  const connectTimeout = setTimeout(() => {
258
- if (!state.connected) {
259
- reject(new Error(`Connection timeout for ${serverId}`));
237
+ if (!this.state.connected) {
238
+ reject(new Error("Connection timeout"));
260
239
  ws.close();
261
240
  }
262
241
  }, 30000); // 30 seconds
263
242
  ws.on("open", () => {
264
243
  clearTimeout(connectTimeout);
265
- state.connected = true;
266
- state.reconnectAttempts = 0;
267
- this.log(`${serverId} connected`);
268
- this.emit("connected", serverId);
244
+ this.state.connected = true;
245
+ this.state.reconnectAttempts = 0;
246
+ this.log("WebSocket connected");
247
+ this.emit("connected");
269
248
  // Send init message
270
- this.sendInitMessage(serverId);
249
+ this.sendInitMessage();
271
250
  resolve();
272
251
  });
273
252
  ws.on("message", (data) => {
274
- this.handleMessage(serverId, data);
253
+ this.handleMessage(data);
275
254
  });
276
255
  ws.on("close", (code, reason) => {
277
- this.handleClose(serverId, code, reason.toString());
256
+ this.handleClose(code, reason.toString());
278
257
  });
279
258
  ws.on("error", (error) => {
280
- this.handleError(serverId, error);
281
- if (!state.connected) {
259
+ this.handleError(error);
260
+ if (!this.state.connected) {
282
261
  clearTimeout(connectTimeout);
283
262
  reject(error);
284
263
  }
285
264
  });
286
265
  });
287
266
  }
288
- /**
289
- * Disconnect from a specific server.
290
- */
291
- disconnectServer(serverId) {
292
- const ws = serverId === "server1" ? this.ws1 : this.ws2;
293
- const heartbeat = serverId === "server1" ? this.heartbeat1 : this.heartbeat2;
294
- const state = serverId === "server1" ? this.state1 : this.state2;
295
- if (heartbeat) {
296
- heartbeat.stop();
297
- if (serverId === "server1") {
298
- this.heartbeat1 = null;
299
- }
300
- else {
301
- this.heartbeat2 = null;
302
- }
303
- }
304
- if (ws) {
305
- ws.removeAllListeners();
306
- if (ws.readyState === WebSocket.OPEN) {
307
- ws.close();
308
- }
309
- if (serverId === "server1") {
310
- this.ws1 = null;
311
- }
312
- else {
313
- this.ws2 = null;
314
- }
315
- }
316
- state.connected = false;
317
- state.ready = false;
318
- }
319
267
  /**
320
268
  * Send init message to server.
321
269
  */
322
- sendInitMessage(serverId) {
323
- const ws = serverId === "server1" ? this.ws1 : this.ws2;
324
- if (!ws || ws.readyState !== WebSocket.OPEN) {
325
- this.error(`Cannot send init message: ${serverId} not open`);
270
+ sendInitMessage() {
271
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
272
+ this.error("Cannot send init message: WebSocket not open");
326
273
  return;
327
274
  }
328
275
  const initMessage = {
@@ -331,24 +278,22 @@ export class XYWebSocketManager extends EventEmitter {
331
278
  msgDetail: JSON.stringify({ agentId: this.config.agentId }),
332
279
  };
333
280
  const initMessageStr = JSON.stringify(initMessage);
334
- console.log(`[WS-${serverId}-SEND] Sending init message frame:`, JSON.stringify(initMessage, null, 2));
335
- ws.send(initMessageStr);
336
- console.log(`[WS-${serverId}-SEND] Init message sent successfully, size: ${initMessageStr.length} bytes`);
281
+ console.log("[WS-SEND] Sending init message frame:", JSON.stringify(initMessage, null, 2));
282
+ this.ws.send(initMessageStr);
283
+ console.log(`[WS-SEND] Init message sent successfully, size: ${initMessageStr.length} bytes`);
337
284
  // Mark as ready after init
338
- const state = serverId === "server1" ? this.state1 : this.state2;
339
- state.ready = true;
340
- this.emit("ready", serverId);
285
+ this.state.ready = true;
286
+ this.emit("ready");
341
287
  // Start heartbeat
342
- this.startHeartbeat(serverId);
288
+ this.startHeartbeat();
343
289
  }
344
290
  /**
345
- * Start heartbeat for a server.
291
+ * Start heartbeat.
346
292
  */
347
- startHeartbeat(serverId) {
348
- const ws = serverId === "server1" ? this.ws1 : this.ws2;
349
- if (!ws)
293
+ startHeartbeat() {
294
+ if (!this.ws)
350
295
  return;
351
- const heartbeat = new HeartbeatManager(ws, {
296
+ const heartbeat = new HeartbeatManager(this.ws, {
352
297
  interval: 30000, // 30 seconds
353
298
  timeout: 10000, // 10 seconds
354
299
  message: JSON.stringify({
@@ -357,203 +302,206 @@ export class XYWebSocketManager extends EventEmitter {
357
302
  msgDetail: JSON.stringify({ timestamp: Date.now() }),
358
303
  }),
359
304
  }, () => {
360
- this.error(`Heartbeat timeout for ${serverId}, reconnecting...`);
361
- this.reconnectServer(serverId);
362
- }, serverId, this.log, this.error, this.onHealthEvent // Pass health event callback
363
- );
305
+ this.error("Heartbeat timeout, reconnecting...");
306
+ // ✅ Close connection first before reconnecting
307
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
308
+ this.log("Closing connection due to heartbeat timeout");
309
+ this.ws.close(); // This will trigger handleClose which will call reconnectServer
310
+ }
311
+ else {
312
+ // Connection already closed, just reconnect
313
+ this.reconnectServer();
314
+ }
315
+ }, "websocket", this.log, this.error, this.onHealthEvent);
364
316
  heartbeat.start();
365
- if (serverId === "server1") {
366
- this.heartbeat1 = heartbeat;
367
- }
368
- else {
369
- this.heartbeat2 = heartbeat;
370
- }
317
+ this.heartbeat = heartbeat;
371
318
  }
372
319
  /**
373
320
  * Handle incoming message from server.
374
321
  */
375
- handleMessage(serverId, data) {
376
- console.log(`[WEBSOCKET-HANDLE] >>>>>>> serverId: ${serverId}, receiving message... <<<<<<<`);
322
+ handleMessage(data) {
323
+ console.log("[WEBSOCKET-HANDLE] >>>>>>> Receiving message... <<<<<<<");
377
324
  try {
378
325
  const messageStr = data.toString();
379
- console.log(`[WS-${serverId}-RECV] Raw message frame, size: ${messageStr.length} bytes`);
326
+ console.log(`[WS-RECV] Raw message frame, size: ${messageStr.length} bytes`);
380
327
  const parsed = JSON.parse(messageStr);
381
- // Log raw message
382
- console.log(`[WS-${serverId}-RECV] Parsed message:`, JSON.stringify(parsed, null, 2));
328
+ console.log("[WS-RECV] Parsed message:", JSON.stringify(parsed, null, 2));
383
329
  // Check if message is in direct A2A JSON-RPC format (server push)
384
330
  if (parsed.jsonrpc === "2.0") {
385
- // Direct A2A format
386
331
  const a2aRequest = parsed;
387
- console.log(`[XY-${serverId}] Message type: Direct A2A JSON-RPC, method: ${a2aRequest.method}`);
332
+ console.log(`[XY] Message type: Direct A2A JSON-RPC, method: ${a2aRequest.method}`);
388
333
  // Extract sessionId from params
389
334
  const sessionId = a2aRequest.params?.sessionId;
390
335
  if (!sessionId) {
391
- console.error(`[XY-${serverId}] Message missing sessionId`);
336
+ console.error("[XY] Message missing sessionId");
392
337
  return;
393
338
  }
394
- console.log(`[XY-${serverId}] Session ID: ${sessionId}`);
395
- // Bind session to this server if not already bound
396
- if (!sessionManager.isBound(sessionId)) {
397
- sessionManager.bind(sessionId, serverId);
398
- console.log(`[XY-${serverId}] Bound session ${sessionId} to ${serverId}`);
399
- }
339
+ console.log(`[XY] Session ID: ${sessionId}`);
400
340
  // Check if message contains only data parts (tool results)
401
341
  const dataParts = a2aRequest.params?.message?.parts?.filter((p) => p.kind === "data");
402
342
  const hasOnlyDataParts = dataParts && dataParts.length > 0 &&
403
343
  dataParts.length === a2aRequest.params?.message?.parts?.length;
404
344
  if (hasOnlyDataParts) {
405
- // This is a data-only message (e.g., intent execution result)
406
- // Only emit data-event, don't send to openclaw
407
- console.log(`[XY-${serverId}] Message contains only data parts, processing as tool result`);
345
+ console.log("[XY] Message contains only data parts, processing as tool result");
408
346
  for (const dataPart of dataParts) {
409
- // Data format: {events: [{header, payload}, ...]}
410
347
  const events = dataPart.data?.events;
411
348
  if (!Array.isArray(events)) {
412
- console.warn(`[XY-${serverId}] dataPart.data.events is not an array, skipping`);
349
+ console.warn("[XY] dataPart.data.events is not an array, skipping");
413
350
  continue;
414
351
  }
415
- console.log(`[XY-${serverId}] Processing ${events.length} events from data.events`);
352
+ console.log(`[XY] Processing ${events.length} events from data.events`);
416
353
  for (const item of events) {
417
- // Check if it's an UploadExeResult (intent execution result)
418
354
  if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
419
355
  const dataEvent = {
420
356
  intentName: item.payload.intentName,
421
357
  outputs: item.payload.outputs || {},
422
358
  status: "success",
423
359
  };
424
- console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
360
+ console.log("[XY] Emitting data-event:", dataEvent);
425
361
  this.emit("data-event", dataEvent);
426
362
  }
427
- // Check if it's an InvokeJarvisGUIAgentResponse
428
363
  else if (item.header?.namespace === "ClawAgent" && item.header?.name === "InvokeJarvisGUIAgentResponse") {
429
- console.log(`[XY-${serverId}] Emitting gui-agent-response:`, item);
364
+ console.log("[XY] Emitting gui-agent-response:", item);
430
365
  this.emit("gui-agent-response", item);
431
366
  }
367
+ else if (item.header?.namespace === "Common" && item.header?.name === "Trigger") {
368
+ console.log("[XY] Trigger event detected, emitting trigger-event with context");
369
+ // 传递完整上下文:event、sessionId、taskId
370
+ this.emit("trigger-event", {
371
+ event: item,
372
+ sessionId: sessionId,
373
+ taskId: a2aRequest.params?.id, // 新的 taskId(点击推送时生成)
374
+ });
375
+ }
432
376
  }
433
377
  }
434
- return; // Don't emit message event
378
+ return;
435
379
  }
436
380
  // Emit message event for non-data-only messages
437
- console.log(`[XY-${serverId}] *** EMITTING message event (Direct A2A path) ***`);
438
- this.emit("message", a2aRequest, sessionId, serverId);
381
+ console.log("[XY] *** EMITTING message event (Direct A2A path) ***");
382
+ this.emit("message", a2aRequest, sessionId);
439
383
  return;
440
384
  }
441
385
  // Wrapped format (InboundWebSocketMessage)
442
386
  const inboundMsg = parsed;
443
- console.log(`[XY-${serverId}] Message type: Wrapped, msgType: ${inboundMsg.msgType}`);
387
+ console.log(`[XY] Message type: Wrapped, msgType: ${inboundMsg.msgType}`);
444
388
  // Handle heartbeat responses
445
389
  if (inboundMsg.msgType === "heartbeat") {
446
- console.log(`[XY-${serverId}] Received heartbeat response`);
447
- // ✅ Report health: application-level heartbeat received
448
- // This prevents openclaw health-monitor from marking connection as stale
390
+ console.log("[XY] Received heartbeat response");
449
391
  this.onHealthEvent?.();
450
392
  return;
451
393
  }
452
- // Handle data messages (e.g., intent execution results)
394
+ // Handle data messages
453
395
  if (inboundMsg.msgType === "data") {
454
- console.log(`[XY-${serverId}] Processing data message`);
396
+ console.log("[XY] Processing data message");
455
397
  try {
456
398
  const a2aRequest = JSON.parse(inboundMsg.msgDetail);
457
399
  const dataParts = a2aRequest.params?.message?.parts?.filter((p) => p.kind === "data");
458
400
  if (dataParts && dataParts.length > 0) {
459
401
  for (const dataPart of dataParts) {
460
- // Data format: {events: [{header, payload}, ...]}
461
402
  const events = dataPart.data?.events;
462
403
  if (!Array.isArray(events)) {
463
- console.warn(`[XY-${serverId}] dataPart.data.events is not an array, skipping`);
404
+ console.warn("[XY] dataPart.data.events is not an array, skipping");
464
405
  continue;
465
406
  }
466
- console.log(`[XY-${serverId}] Processing ${events.length} events from data.events`);
407
+ console.log(`[XY] Processing ${events.length} events from data.events`);
467
408
  for (const item of events) {
468
- // Check if it's an UploadExeResult (intent execution result)
469
409
  if (item.header?.name === "UploadExeResult" && item.payload?.intentName) {
470
410
  const dataEvent = {
471
411
  intentName: item.payload.intentName,
472
412
  outputs: item.payload.outputs || {},
473
413
  status: "success",
474
414
  };
475
- console.log(`[XY-${serverId}] Emitting data-event:`, dataEvent);
415
+ console.log("[XY] Emitting data-event:", dataEvent);
476
416
  this.emit("data-event", dataEvent);
477
417
  }
478
- // Check if it's an InvokeJarvisGUIAgentResponse
479
418
  else if (item.header?.namespace === "ClawAgent" && item.header?.name === "InvokeJarvisGUIAgentResponse") {
480
- console.log(`[XY-${serverId}] Emitting gui-agent-response:`, item);
419
+ console.log("[XY] Emitting gui-agent-response:", item);
481
420
  this.emit("gui-agent-response", item);
482
421
  }
422
+ else if (item.header?.namespace === "Common" && item.header?.name === "Trigger") {
423
+ console.log("[XY] Trigger event detected (wrapped format), emitting trigger-event with context");
424
+ // 传递完整上下文:event、sessionId、taskId
425
+ this.emit("trigger-event", {
426
+ event: item,
427
+ sessionId: inboundMsg.sessionId || a2aRequest.params?.sessionId,
428
+ taskId: inboundMsg.taskId || a2aRequest.params?.id,
429
+ });
430
+ }
483
431
  }
484
432
  }
485
433
  }
486
434
  }
487
435
  catch (error) {
488
- console.error(`[XY-${serverId}] Failed to process data message:`, error);
436
+ console.error("[XY] Failed to process data message:", error);
489
437
  }
490
438
  return;
491
439
  }
492
440
  // Parse msgDetail as A2AJsonRpcRequest
493
441
  const a2aRequest = JSON.parse(inboundMsg.msgDetail);
494
- console.log(`[XY-${serverId}] Parsed A2A request, method: ${a2aRequest.method}`);
495
- // Bind session to this server if not already bound
442
+ console.log(`[XY] Parsed A2A request, method: ${a2aRequest.method}`);
496
443
  const sessionId = inboundMsg.sessionId;
497
- if (!sessionManager.isBound(sessionId)) {
498
- sessionManager.bind(sessionId, serverId);
499
- console.log(`[XY-${serverId}] Bound session ${sessionId} to ${serverId}`);
500
- }
501
- console.log(`[XY-${serverId}] Session ID: ${sessionId}`);
444
+ console.log(`[XY] Session ID: ${sessionId}`);
502
445
  // Emit message event
503
- console.log(`[XY-${serverId}] *** EMITTING message event (Wrapped path) ***`);
504
- this.emit("message", a2aRequest, sessionId, serverId);
446
+ console.log("[XY] *** EMITTING message event (Wrapped path) ***");
447
+ this.emit("message", a2aRequest, sessionId);
505
448
  }
506
449
  catch (error) {
507
- console.error(`[XY-${serverId}] Failed to parse message:`, error);
450
+ console.error("[XY] Failed to parse message:", error);
508
451
  }
509
452
  }
510
453
  /**
511
454
  * Handle connection close.
512
455
  */
513
- handleClose(serverId, code, reason) {
514
- console.warn(`${serverId} disconnected: code=${code}, reason=${reason}`);
515
- const state = serverId === "server1" ? this.state1 : this.state2;
516
- state.connected = false;
517
- state.ready = false;
518
- this.emit("disconnected", serverId);
519
- // Stop heartbeat
520
- const heartbeat = serverId === "server1" ? this.heartbeat1 : this.heartbeat2;
521
- if (heartbeat) {
522
- heartbeat.stop();
456
+ handleClose(code, reason) {
457
+ console.warn(`WebSocket disconnected: code=${code}, reason=${reason}`);
458
+ // Only process if this is the current connection
459
+ if (!this.ws) {
460
+ this.log("Ignoring close event for already cleaned connection");
461
+ return;
462
+ }
463
+ this.state.connected = false;
464
+ this.state.ready = false;
465
+ this.emit("disconnected");
466
+ // Clean up
467
+ if (this.heartbeat) {
468
+ this.heartbeat.stop();
469
+ this.heartbeat = null;
523
470
  }
471
+ this.ws.removeAllListeners();
472
+ this.ws = null;
524
473
  // Attempt reconnection if not shutting down
525
474
  if (!this.isShuttingDown) {
526
- this.reconnectServer(serverId);
475
+ this.reconnectServer();
527
476
  }
528
477
  }
529
478
  /**
530
479
  * Handle connection error.
531
480
  */
532
- handleError(serverId, error) {
533
- this.error(`${serverId} error:`, error);
534
- this.emit("error", error, serverId);
481
+ handleError(error) {
482
+ this.error("WebSocket error:", error);
483
+ this.emit("error", error);
535
484
  }
536
485
  /**
537
- * Reconnect to a server with exponential backoff.
486
+ * Reconnect with exponential backoff.
538
487
  */
539
- reconnectServer(serverId) {
488
+ reconnectServer() {
540
489
  if (this.isShuttingDown)
541
490
  return;
542
- const state = serverId === "server1" ? this.state1 : this.state2;
543
- state.reconnectAttempts++;
544
- const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts - 1), 30000);
545
- this.log(`Reconnecting to ${serverId} in ${delay}ms (attempt ${state.reconnectAttempts})...`);
491
+ // Clear existing reconnect timer
492
+ if (this.reconnectTimer) {
493
+ clearTimeout(this.reconnectTimer);
494
+ this.log("Cleared existing reconnect timer to prevent concurrent reconnection");
495
+ }
496
+ this.state.reconnectAttempts++;
497
+ const delay = Math.min(1000 * Math.pow(2, this.state.reconnectAttempts - 1), 30000);
498
+ this.log(`Reconnecting in ${delay}ms (attempt ${this.state.reconnectAttempts})...`);
546
499
  const timer = setTimeout(() => {
547
- const url = serverId === "server1" ? this.config.wsUrl1 : this.config.wsUrl2;
548
- this.connectServer(serverId, url).catch((error) => {
549
- this.error(`Reconnection failed for ${serverId}:`, error);
500
+ this.reconnectTimer = null;
501
+ this.connectServer(this.config.wsUrl).catch((error) => {
502
+ this.error("Reconnection failed:", error);
550
503
  });
551
504
  }, delay);
552
- if (serverId === "server1") {
553
- this.reconnectTimer1 = timer;
554
- }
555
- else {
556
- this.reconnectTimer2 = timer;
557
- }
505
+ this.reconnectTimer = timer;
558
506
  }
559
507
  }