@ynhcj/xiaoyi 2.5.5 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/channel.d.ts CHANGED
@@ -30,6 +30,63 @@ export declare const xiaoyiPlugin: {
30
30
  media: boolean;
31
31
  nativeCommands: boolean;
32
32
  };
33
+ /**
34
+ * Config schema for UI form rendering
35
+ */
36
+ configSchema: {
37
+ schema: {
38
+ type: string;
39
+ properties: {
40
+ enabled: {
41
+ type: string;
42
+ default: boolean;
43
+ description: string;
44
+ };
45
+ wsUrl1: {
46
+ type: string;
47
+ default: string;
48
+ description: string;
49
+ };
50
+ wsUrl2: {
51
+ type: string;
52
+ default: string;
53
+ description: string;
54
+ };
55
+ ak: {
56
+ type: string;
57
+ description: string;
58
+ };
59
+ sk: {
60
+ type: string;
61
+ description: string;
62
+ };
63
+ agentId: {
64
+ type: string;
65
+ description: string;
66
+ };
67
+ debug: {
68
+ type: string;
69
+ default: boolean;
70
+ description: string;
71
+ };
72
+ apiId: {
73
+ type: string;
74
+ default: string;
75
+ description: string;
76
+ };
77
+ pushId: {
78
+ type: string;
79
+ default: string;
80
+ description: string;
81
+ };
82
+ taskTimeoutMs: {
83
+ type: string;
84
+ default: number;
85
+ description: string;
86
+ };
87
+ };
88
+ };
89
+ };
33
90
  onboarding: any;
34
91
  /**
35
92
  * Config adapter - single account mode
package/dist/channel.js CHANGED
@@ -32,6 +32,63 @@ exports.xiaoyiPlugin = {
32
32
  media: true,
33
33
  nativeCommands: false,
34
34
  },
35
+ /**
36
+ * Config schema for UI form rendering
37
+ */
38
+ configSchema: {
39
+ schema: {
40
+ type: "object",
41
+ properties: {
42
+ enabled: {
43
+ type: "boolean",
44
+ default: false,
45
+ description: "Enable XiaoYi channel",
46
+ },
47
+ wsUrl1: {
48
+ type: "string",
49
+ default: "wss://hag.cloud.huawei.com/openclaw/v1/ws/link",
50
+ description: "Primary WebSocket server URL",
51
+ },
52
+ wsUrl2: {
53
+ type: "string",
54
+ default: "wss://116.63.174.231/openclaw/v1/ws/link",
55
+ description: "Secondary WebSocket server URL",
56
+ },
57
+ ak: {
58
+ type: "string",
59
+ description: "Access Key",
60
+ },
61
+ sk: {
62
+ type: "string",
63
+ description: "Secret Key",
64
+ },
65
+ agentId: {
66
+ type: "string",
67
+ description: "Agent ID",
68
+ },
69
+ debug: {
70
+ type: "boolean",
71
+ default: false,
72
+ description: "Enable debug logging",
73
+ },
74
+ apiId: {
75
+ type: "string",
76
+ default: "",
77
+ description: "API ID for push notifications",
78
+ },
79
+ pushId: {
80
+ type: "string",
81
+ default: "",
82
+ description: "Push ID for push notifications",
83
+ },
84
+ taskTimeoutMs: {
85
+ type: "number",
86
+ default: 3600000,
87
+ description: "Task timeout in milliseconds (default: 1 hour)",
88
+ },
89
+ },
90
+ },
91
+ },
35
92
  onboarding: onboarding_1.xiaoyiOnboardingAdapter,
36
93
  /**
37
94
  * Config adapter - single account mode
@@ -251,10 +308,13 @@ exports.xiaoyiPlugin = {
251
308
  // where the outer scope's resolvedAccount might become unavailable
252
309
  const messageHandlerAgentId = resolvedAccount.config?.agentId;
253
310
  const messageHandlerAccountId = resolvedAccount.accountId;
311
+ const messageHandlerConfig = resolvedAccount.config;
254
312
  if (!messageHandlerAgentId) {
255
313
  console.error("XiaoYi: [FATAL] agentId not available in resolvedAccount.config");
256
314
  return;
257
315
  }
316
+ // Set task timeout time from configuration
317
+ runtime.setTaskTimeout(messageHandlerConfig.taskTimeoutMs || 3600000);
258
318
  console.log(`XiaoYi: [Message Handler] Stored config values - agentId: ${messageHandlerAgentId}, accountId: ${messageHandlerAccountId}`);
259
319
  // For message/stream, prioritize params.sessionId, fallback to top-level sessionId
260
320
  const sessionId = message.params?.sessionId || message.sessionId;
@@ -418,7 +478,6 @@ exports.xiaoyiPlugin = {
418
478
  const startTime = Date.now();
419
479
  let accumulatedText = "";
420
480
  let sentTextLength = 0; // Track sent text length for streaming
421
- let hasSentFinal = false; // Track if final content has been sent (to prevent duplicate isFinal=true)
422
481
  // ==================== CREATE ABORT CONTROLLER ====================
423
482
  // Create AbortController for this session to allow cancelation
424
483
  const abortControllerResult = runtime.createAbortControllerForSession(sessionId);
@@ -428,24 +487,62 @@ exports.xiaoyiPlugin = {
428
487
  }
429
488
  const { controller: abortController, signal: abortSignal } = abortControllerResult;
430
489
  // ================================================================
431
- // ==================== START TIMEOUT PROTECTION ====================
432
- // Start periodic 60-second timeout timer
433
- // Will trigger every 60 seconds until a response is received or session completes
490
+ // ==================== 1-HOUR TASK TIMEOUT PROTECTION ====================
491
+ // Start 1-hour task timeout timer
492
+ // Will trigger once after 1 hour if no response received
493
+ console.log(`[TASK TIMEOUT] Starting ${messageHandlerConfig.taskTimeoutMs || 3600000}ms task timeout protection for session ${sessionId}`);
494
+ // Define task timeout handler (will be called once after 1 hour)
495
+ const createTaskTimeoutHandler = () => {
496
+ return async (timeoutSessionId, timeoutTaskId) => {
497
+ const elapsed = Date.now() - startTime;
498
+ console.log("\n" + "=".repeat(60));
499
+ console.log(`[TASK TIMEOUT] 1-hour timeout triggered for session ${sessionId}`);
500
+ console.log(` Elapsed: ${elapsed}ms`);
501
+ console.log(` Task ID: ${currentTaskId}`);
502
+ console.log("=".repeat(60) + "\n");
503
+ const conn = runtime.getConnection();
504
+ if (conn) {
505
+ try {
506
+ // Send default message with isFinal=true
507
+ await conn.sendResponse({
508
+ sessionId: timeoutSessionId,
509
+ messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
510
+ timestamp: Date.now(),
511
+ agentId: messageHandlerAgentId,
512
+ sender: { id: messageHandlerAgentId, name: "OpenClaw Agent", type: "agent" },
513
+ content: { type: "text", text: "任务还在处理中,完成后将提醒您~" },
514
+ status: "success",
515
+ }, timeoutTaskId, timeoutSessionId, true, false); // isFinal=true
516
+ console.log(`[TASK TIMEOUT] Default message sent (isFinal=true) to session ${timeoutSessionId}\n`);
517
+ }
518
+ catch (error) {
519
+ console.error(`[TASK TIMEOUT] Failed to send default message:`, error);
520
+ }
521
+ }
522
+ // Cancel 60-second periodic timeout
523
+ runtime.clearSessionTimeout(timeoutSessionId);
524
+ // Mark as waiting for push state
525
+ runtime.markSessionWaitingForPush(timeoutSessionId, timeoutTaskId);
526
+ };
527
+ };
528
+ // Start 1-hour task timeout timer
529
+ runtime.setTaskTimeoutForSession(sessionId, currentTaskId, createTaskTimeoutHandler());
530
+ // Also start 60-second periodic timeout for status updates (for messages before 1-hour timeout)
434
531
  const timeoutConfig = runtime.getTimeoutConfig();
435
- console.log(`[TIMEOUT] Starting ${timeoutConfig.duration}ms periodic timeout protection for session ${sessionId}`);
436
- // Define periodic timeout handler (will be called every 60 seconds)
437
- const createTimeoutHandler = () => {
532
+ const createPeriodicTimeoutHandler = () => {
438
533
  return async () => {
534
+ // Skip if already waiting for push (1-hour timeout already triggered)
535
+ if (runtime.isSessionWaitingForPush(sessionId, currentTaskId)) {
536
+ return;
537
+ }
439
538
  const elapsed = Date.now() - startTime;
440
539
  console.log("\n" + "=".repeat(60));
441
- console.log(`[TIMEOUT] Timeout triggered for session ${sessionId}`);
540
+ console.log(`[TIMEOUT] Periodic timeout triggered for session ${sessionId}`);
442
541
  console.log(` Elapsed: ${elapsed}ms`);
443
- console.log(` Task ID: ${currentTaskId}`);
444
542
  console.log("=".repeat(60) + "\n");
445
543
  const conn = runtime.getConnection();
446
544
  if (conn) {
447
545
  try {
448
- // Send status update to keep conversation active
449
546
  await conn.sendStatusUpdate(currentTaskId, sessionId, timeoutConfig.message);
450
547
  console.log(`[TIMEOUT] Status update sent successfully to session ${sessionId}\n`);
451
548
  }
@@ -453,18 +550,15 @@ exports.xiaoyiPlugin = {
453
550
  console.error(`[TIMEOUT] Failed to send status update:`, error);
454
551
  }
455
552
  }
456
- else {
457
- console.error(`[TIMEOUT] Connection not available, cannot send status update\n`);
458
- }
459
- // Note: Timeout will trigger again in 60 seconds if still active
460
553
  };
461
554
  };
462
- // Start periodic timeout
463
- runtime.setTimeoutForSession(sessionId, createTimeoutHandler());
464
- // ==================== END TIMEOUT PROTECTION ====================
555
+ runtime.setTimeoutForSession(sessionId, createPeriodicTimeoutHandler());
556
+ // ==================== END TASK TIMEOUT PROTECTION ====================
465
557
  // ==================== CREATE STREAMING DISPATCHER ====================
466
- // Use createReplyDispatcherWithTyping for real-time streaming feedback
467
- const { dispatcher, replyOptions, markDispatchIdle } = pluginRuntime.channel.reply.createReplyDispatcherWithTyping({
558
+ // ==================== DISPATCHER OPTIONS ====================
559
+ // Define dispatcher options for dispatchInboundMessageWithBufferedDispatcher
560
+ // This uses the standard OpenClaw pattern which properly handles dispatcher lifecycle
561
+ const dispatcherOptions = {
468
562
  humanDelay: 0,
469
563
  onReplyStart: async () => {
470
564
  const elapsed = Date.now() - startTime;
@@ -494,6 +588,11 @@ exports.xiaoyiPlugin = {
494
588
  // Check multiple sources: payload.status, payload.queuedFinal, AND info.kind
495
589
  // info.kind is the most reliable indicator for final messages
496
590
  const isFinal = payloadStatus === "final" || payload.queuedFinal === true || kind === "final";
591
+ // If session is waiting for push (1-hour timeout occurred), ignore non-final responses
592
+ if (runtime.isSessionWaitingForPush(sessionId, currentTaskId) && !payload.queuedFinal && info.kind !== "final") {
593
+ console.log(`[TASK TIMEOUT] Ignoring non-final response for session ${sessionId} (already timed out)`);
594
+ return;
595
+ }
497
596
  accumulatedText = text;
498
597
  console.log("\n" + "█".repeat(70));
499
598
  console.log("📨 [DELIVER] Payload received");
@@ -547,7 +646,12 @@ exports.xiaoyiPlugin = {
547
646
  // ==================== END FIX ====================
548
647
  const responseStatus = isFinal ? "success" : "processing";
549
648
  const incrementalText = text.slice(sentTextLength);
550
- const isAppend = !isFinal && incrementalText.length > 0;
649
+ // ==================== FIX: Always send isFinal=false in deliver ====================
650
+ // All responses from deliver callback are sent with isFinal=false
651
+ // The final isFinal=true will be sent in onIdle callback when ALL processing is complete
652
+ const shouldSendFinal = false;
653
+ // Always use append=true for all responses
654
+ const isAppend = true;
551
655
  if (incrementalText.length > 0 || isFinal) {
552
656
  console.log("\n" + "-".repeat(60));
553
657
  console.log("XiaoYi: [STREAM] Sending response");
@@ -571,16 +675,9 @@ exports.xiaoyiPlugin = {
571
675
  },
572
676
  status: responseStatus,
573
677
  };
574
- // ==================== FIX: Prevent duplicate final messages ====================
575
- // Only send isFinal=true if we haven't sent it before AND this is actually the final message
576
- const shouldSendFinal = isFinal && !hasSentFinal;
577
678
  try {
578
679
  await conn.sendResponse(response, currentTaskId, sessionId, shouldSendFinal, isAppend);
579
- console.log("✓ Sent (status=" + responseStatus + ", isFinal=" + shouldSendFinal + ", append=" + isAppend + ")\n");
580
- // Mark that we've sent a final message (even if we're still processing subagent responses)
581
- if (isFinal) {
582
- hasSentFinal = true;
583
- }
680
+ console.log("✓ Sent (status=" + responseStatus + ", isFinal=false, append=" + isAppend + ")\n");
584
681
  }
585
682
  catch (error) {
586
683
  console.error("✗ Failed to send:", error);
@@ -606,7 +703,14 @@ exports.xiaoyiPlugin = {
606
703
  console.error("XiaoYi: [ERROR] " + info.kind + " failed: " + String(err));
607
704
  console.log("=".repeat(60) + "\n");
608
705
  runtime.clearSessionTimeout(sessionId);
706
+ runtime.clearTaskTimeoutForSession(sessionId);
707
+ runtime.clearSessionWaitingForPush(sessionId, currentTaskId);
609
708
  runtime.clearAbortControllerForSession(sessionId);
709
+ // Check if session was cleared
710
+ const conn = runtime.getConnection();
711
+ if (conn && conn.isSessionPendingCleanup(sessionId)) {
712
+ conn.forceCleanupSession(sessionId);
713
+ }
610
714
  runtime.markSessionCompleted(sessionId);
611
715
  },
612
716
  onIdle: async () => {
@@ -615,27 +719,101 @@ exports.xiaoyiPlugin = {
615
719
  console.log("XiaoYi: [IDLE] Processing complete");
616
720
  console.log(" Total time: " + elapsed + "ms");
617
721
  console.log("=".repeat(60) + "\n");
618
- // ==================== PUSH MESSAGE FOR BACKGROUND RESULTS ====================
619
- // NOTE: Push logic disabled because we cannot reliably distinguish between:
620
- // - Normal responses (should be sent via WebSocket)
621
- // - Background task completion (should be sent via HTTP push)
622
- // TODO: Implement proper push message detection and HTTP API call
623
- console.log("[IDLE] All agent processing complete");
624
- // ==================== END PUSH MESSAGE ====================
722
+ // Clear 1-hour task timeout timer
723
+ runtime.clearTaskTimeoutForSession(sessionId);
724
+ // ==================== CHECK IF SESSION WAS CLEARED ====================
725
+ const conn = runtime.getConnection();
726
+ const isPendingCleanup = conn && conn.isSessionPendingCleanup(sessionId);
727
+ const isWaitingForPush = runtime.isSessionWaitingForPush(sessionId, currentTaskId);
728
+ // ==================== PUSH NOTIFICATION LOGIC ====================
729
+ // Send push if task timeout was triggered (regardless of session cleanup status)
730
+ // This ensures users get notified when long-running tasks complete
731
+ if (isWaitingForPush && accumulatedText.length > 0) {
732
+ const pushReason = isPendingCleanup
733
+ ? `Session ${sessionId} was cleared`
734
+ : `Session ${sessionId} task timeout triggered`;
735
+ console.log(`[CLEANUP] ${pushReason}, sending push notification`);
736
+ try {
737
+ const { XiaoYiPushService } = require("./push");
738
+ const pushService = new XiaoYiPushService(messageHandlerConfig);
739
+ if (pushService.isConfigured()) {
740
+ // Generate summary
741
+ const summary = accumulatedText.length > 30
742
+ ? accumulatedText.substring(0, 30) + "..."
743
+ : accumulatedText;
744
+ await pushService.sendPush(summary, "后台任务已完成:" + summary);
745
+ console.log("✓ [CLEANUP] Push notification sent\n");
746
+ // Clear push waiting state for this specific task
747
+ runtime.clearSessionWaitingForPush(sessionId, currentTaskId);
748
+ }
749
+ else {
750
+ console.log("[CLEANUP] Push not configured, skipping notification");
751
+ runtime.clearSessionWaitingForPush(sessionId, currentTaskId);
752
+ }
753
+ }
754
+ catch (error) {
755
+ console.error("[CLEANUP] Error sending push:", error);
756
+ runtime.clearSessionWaitingForPush(sessionId, currentTaskId);
757
+ }
758
+ // If session was cleared, update cleanup state
759
+ if (isPendingCleanup) {
760
+ conn?.updateAccumulatedTextForCleanup(sessionId, accumulatedText);
761
+ conn?.forceCleanupSession(sessionId);
762
+ }
763
+ }
764
+ // ==================== NORMAL WEBSOCKET FLOW (no timeout triggered) ====================
765
+ else if (!isPendingCleanup) {
766
+ // Normal flow: send WebSocket response (no timeout, session still active)
767
+ const conn = runtime.getConnection();
768
+ if (conn) {
769
+ try {
770
+ await conn.sendResponse({
771
+ sessionId: sessionId,
772
+ messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
773
+ timestamp: Date.now(),
774
+ agentId: messageHandlerAgentId,
775
+ sender: {
776
+ id: messageHandlerAgentId,
777
+ name: "OpenClaw Agent",
778
+ type: "agent",
779
+ },
780
+ content: {
781
+ type: "text",
782
+ text: accumulatedText,
783
+ },
784
+ status: "success",
785
+ }, currentTaskId, sessionId, true, true); // isFinal=true, append=true
786
+ console.log("✓ [IDLE] Final response sent (isFinal=true)\n");
787
+ }
788
+ catch (error) {
789
+ console.error("✗ [IDLE] Failed to send final response:", error);
790
+ }
791
+ }
792
+ }
793
+ // ==================== SESSION CLEARED BUT NO TIMEOUT ====================
794
+ else {
795
+ // Session was cleared but no timeout triggered - edge case, just cleanup
796
+ console.log(`[CLEANUP] Session ${sessionId} was cleared but no push needed`);
797
+ conn?.forceCleanupSession(sessionId);
798
+ }
625
799
  // This is called AFTER all processing is done (including subagents)
626
800
  // NOW we can safely mark the session as completed
627
801
  runtime.clearAbortControllerForSession(sessionId);
628
802
  runtime.markSessionCompleted(sessionId);
629
803
  console.log("[CLEANUP] Session marked as completed in onIdle\n");
630
804
  },
631
- });
805
+ };
632
806
  try {
633
- const result = await pluginRuntime.channel.reply.dispatchReplyFromConfig({
807
+ // Use standard OpenClaw pattern with dispatchReplyWithBufferedBlockDispatcher
808
+ // This properly handles dispatcher lifecycle:
809
+ // 1. Calls dispatcher.markComplete() after run() completes
810
+ // 2. Waits for waitForIdle() to ensure all deliveries are done
811
+ // 3. Then calls markDispatchIdle() in the finally block
812
+ const result = await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
634
813
  ctx: msgContext,
635
814
  cfg: config,
636
- dispatcher,
815
+ dispatcherOptions: dispatcherOptions,
637
816
  replyOptions: {
638
- ...replyOptions,
639
817
  abortSignal: abortSignal,
640
818
  },
641
819
  });
@@ -687,7 +865,7 @@ exports.xiaoyiPlugin = {
687
865
  status: "success",
688
866
  };
689
867
  // Send response with isFinal=true to close THIS request
690
- await conn.sendResponse(response, currentTaskId, sessionId, true, false);
868
+ await conn.sendResponse(response, currentTaskId, sessionId, true, true);
691
869
  console.log("✓ [NO OUTPUT] Response sent to user\n");
692
870
  }
693
871
  catch (error) {
@@ -698,14 +876,16 @@ exports.xiaoyiPlugin = {
698
876
  // The original Agent might still be running and needs these resources
699
877
  // onIdle will be called when the original Agent completes
700
878
  console.log("[NO OUTPUT] Keeping resources alive for potential background Agent\n");
701
- markDispatchIdle();
879
+ // Note: No need to call markDispatchIdle() manually
880
+ // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block
702
881
  }
703
882
  else {
704
883
  // Scenario 2: Normal execution with output
705
884
  // - Agent produced output synchronously
706
885
  // - All cleanup is already handled in deliver/onIdle callbacks
707
886
  console.log("[NORMAL] Agent produced output, cleanup handled in callbacks");
708
- markDispatchIdle();
887
+ // Note: No need to call markDispatchIdle() manually
888
+ // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block
709
889
  }
710
890
  // ==================== END ANALYSIS ====================
711
891
  }
@@ -713,12 +893,14 @@ exports.xiaoyiPlugin = {
713
893
  console.error("XiaoYi: [ERROR] Error dispatching message:", error);
714
894
  // Clear timeout on error
715
895
  runtime.clearSessionTimeout(sessionId);
896
+ runtime.clearTaskTimeoutForSession(sessionId);
897
+ runtime.clearSessionWaitingForPush(sessionId, currentTaskId);
716
898
  // Clear abort controller on error
717
899
  runtime.clearAbortControllerForSession(sessionId);
718
900
  // Mark session as completed on error
719
901
  runtime.markSessionCompleted(sessionId);
720
- // Mark dispatcher as idle even on error
721
- markDispatchIdle();
902
+ // Note: No need to call markDispatchIdle() manually
903
+ // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block
722
904
  }
723
905
  }
724
906
  catch (error) {
@@ -742,9 +924,33 @@ exports.xiaoyiPlugin = {
742
924
  else {
743
925
  console.log(`[CANCEL] No active agent run found for session ${sessionId}`);
744
926
  }
745
- // Clear timeout as the session is being canceled
927
+ // Clear timeout and push state as the session is being canceled
928
+ runtime.clearTaskTimeoutForSession(sessionId);
929
+ runtime.clearSessionWaitingForPush(sessionId, data.taskId);
746
930
  runtime.markSessionCompleted(sessionId);
747
931
  });
932
+ // Handle clear context events
933
+ connection.on("clear", async (data) => {
934
+ const { sessionId, serverId } = data;
935
+ console.log("\n" + "=".repeat(60));
936
+ console.log("[CLEAR] Context cleared by user");
937
+ console.log(` Session: ${sessionId}`);
938
+ console.log("=".repeat(60) + "\n");
939
+ // Check if there's an active task for this session
940
+ const hasActiveTask = runtime.isSessionActive(sessionId);
941
+ if (hasActiveTask) {
942
+ console.log(`[CLEAR] Active task exists for session ${sessionId}, will continue in background`);
943
+ // Session is already marked for cleanup in websocket.ts
944
+ // Just track that we're waiting for completion
945
+ }
946
+ else {
947
+ console.log(`[CLEAR] No active task for session ${sessionId}, clean up immediately`);
948
+ const conn = runtime.getConnection();
949
+ if (conn) {
950
+ conn.forceCleanupSession(sessionId);
951
+ }
952
+ }
953
+ });
748
954
  // Mark handlers as registered to prevent duplicate registration
749
955
  handlersRegistered = true;
750
956
  }
@@ -8,8 +8,10 @@ export declare const XiaoYiConfigSchema: z.ZodObject<{
8
8
  name: z.ZodOptional<z.ZodString>;
9
9
  /** Whether this channel is enabled */
10
10
  enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
11
- /** WebSocket URL for A2A connection */
12
- wsUrl: z.ZodOptional<z.ZodString>;
11
+ /** First WebSocket server URL */
12
+ wsUrl1: z.ZodDefault<z.ZodOptional<z.ZodString>>;
13
+ /** Second WebSocket server URL */
14
+ wsUrl2: z.ZodDefault<z.ZodOptional<z.ZodString>>;
13
15
  /** Access Key for authentication */
14
16
  ak: z.ZodOptional<z.ZodString>;
15
17
  /** Secret Key for authentication */
@@ -18,27 +20,25 @@ export declare const XiaoYiConfigSchema: z.ZodObject<{
18
20
  agentId: z.ZodOptional<z.ZodString>;
19
21
  /** Enable debug logging */
20
22
  debug: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
21
- /** Enable streaming responses (default: false) */
22
- enableStreaming: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
23
23
  /** Multi-account configuration */
24
24
  accounts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
25
25
  }, "strip", z.ZodTypeAny, {
26
26
  enabled?: boolean;
27
- wsUrl?: string;
27
+ wsUrl1?: string;
28
+ wsUrl2?: string;
28
29
  ak?: string;
29
30
  sk?: string;
30
31
  agentId?: string;
31
- enableStreaming?: boolean;
32
32
  name?: string;
33
33
  debug?: boolean;
34
34
  accounts?: Record<string, unknown>;
35
35
  }, {
36
36
  enabled?: boolean;
37
- wsUrl?: string;
37
+ wsUrl1?: string;
38
+ wsUrl2?: string;
38
39
  ak?: string;
39
40
  sk?: string;
40
41
  agentId?: string;
41
- enableStreaming?: boolean;
42
42
  name?: string;
43
43
  debug?: boolean;
44
44
  accounts?: Record<string, unknown>;
@@ -10,9 +10,11 @@ exports.XiaoYiConfigSchema = zod_1.z.object({
10
10
  /** Account name (optional display name) */
11
11
  name: zod_1.z.string().optional(),
12
12
  /** Whether this channel is enabled */
13
- enabled: zod_1.z.boolean().optional().default(true),
14
- /** WebSocket URL for A2A connection */
15
- wsUrl: zod_1.z.string().optional(),
13
+ enabled: zod_1.z.boolean().optional().default(false),
14
+ /** First WebSocket server URL */
15
+ wsUrl1: zod_1.z.string().optional().default("wss://hag.cloud.huawei.com/openclaw/v1/ws/link"),
16
+ /** Second WebSocket server URL */
17
+ wsUrl2: zod_1.z.string().optional().default("wss://116.63.174.231/openclaw/v1/ws/link"),
16
18
  /** Access Key for authentication */
17
19
  ak: zod_1.z.string().optional(),
18
20
  /** Secret Key for authentication */
@@ -21,8 +23,6 @@ exports.XiaoYiConfigSchema = zod_1.z.object({
21
23
  agentId: zod_1.z.string().optional(),
22
24
  /** Enable debug logging */
23
25
  debug: zod_1.z.boolean().optional().default(false),
24
- /** Enable streaming responses (default: false) */
25
- enableStreaming: zod_1.z.boolean().optional().default(false),
26
26
  /** Multi-account configuration */
27
27
  accounts: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
28
28
  });
package/dist/index.d.ts CHANGED
@@ -14,13 +14,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
14
  * "wsUrl2": "ws://localhost:8766/ws/link",
15
15
  * "ak": "test_ak",
16
16
  * "sk": "test_sk",
17
- * "agentId": "your-agent-id",
18
- * "enableStreaming": true
17
+ * "agentId": "your-agent-id"
19
18
  * }
20
19
  * }
21
20
  * }
22
- *
23
- * Backward compatibility: Can use "wsUrl" instead of "wsUrl1" (wsUrl2 will use default)
24
21
  */
25
22
  declare const plugin: {
26
23
  id: string;
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- const plugin_sdk_1 = require("openclaw/plugin-sdk");
4
3
  const channel_1 = require("./channel");
5
4
  const runtime_1 = require("./runtime");
6
5
  /**
@@ -18,19 +17,16 @@ const runtime_1 = require("./runtime");
18
17
  * "wsUrl2": "ws://localhost:8766/ws/link",
19
18
  * "ak": "test_ak",
20
19
  * "sk": "test_sk",
21
- * "agentId": "your-agent-id",
22
- * "enableStreaming": true
20
+ * "agentId": "your-agent-id"
23
21
  * }
24
22
  * }
25
23
  * }
26
- *
27
- * Backward compatibility: Can use "wsUrl" instead of "wsUrl1" (wsUrl2 will use default)
28
24
  */
29
25
  const plugin = {
30
26
  id: "xiaoyi",
31
27
  name: "XiaoYi Channel",
32
28
  description: "XiaoYi channel plugin with A2A protocol support",
33
- configSchema: (0, plugin_sdk_1.emptyPluginConfigSchema)(),
29
+ configSchema: undefined,
34
30
  register(api) {
35
31
  console.log("XiaoYi: register() called - START");
36
32
  // Set runtime for managing WebSocket connections
package/dist/push.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { XiaoYiChannelConfig } from "./types";
2
+ /**
3
+ * Push message sending service
4
+ * Sends notifications to XiaoYi clients via webhook API
5
+ */
6
+ export declare class XiaoYiPushService {
7
+ private config;
8
+ private readonly pushUrl;
9
+ constructor(config: XiaoYiChannelConfig);
10
+ /**
11
+ * Check if push functionality is configured
12
+ */
13
+ isConfigured(): boolean;
14
+ /**
15
+ * Generate HMAC-SHA256 signature
16
+ */
17
+ private generateSignature;
18
+ /**
19
+ * Generate UUID
20
+ */
21
+ private generateUUID;
22
+ /**
23
+ * Send push notification (with summary text)
24
+ * @param text - Summary text to send (e.g., first 30 characters)
25
+ * @param pushText - Push notification message (e.g., "任务已完成:xxx...")
26
+ */
27
+ sendPush(text: string, pushText: string): Promise<boolean>;
28
+ }
package/dist/push.js ADDED
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.XiaoYiPushService = void 0;
37
+ const crypto = __importStar(require("crypto"));
38
+ /**
39
+ * Push message sending service
40
+ * Sends notifications to XiaoYi clients via webhook API
41
+ */
42
+ class XiaoYiPushService {
43
+ constructor(config) {
44
+ this.pushUrl = "https://hag.cloud.huawei.com/open-ability-agent/v1/agent-webhook";
45
+ this.config = config;
46
+ }
47
+ /**
48
+ * Check if push functionality is configured
49
+ */
50
+ isConfigured() {
51
+ return Boolean(this.config.apiId?.trim() &&
52
+ this.config.pushId?.trim() &&
53
+ this.config.ak?.trim() &&
54
+ this.config.sk?.trim());
55
+ }
56
+ /**
57
+ * Generate HMAC-SHA256 signature
58
+ */
59
+ generateSignature(timestamp) {
60
+ const hmac = crypto.createHmac("sha256", this.config.sk);
61
+ hmac.update(timestamp);
62
+ return hmac.digest().toString("base64");
63
+ }
64
+ /**
65
+ * Generate UUID
66
+ */
67
+ generateUUID() {
68
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
69
+ const r = (Math.random() * 16) | 0;
70
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
71
+ return v.toString(16);
72
+ });
73
+ }
74
+ /**
75
+ * Send push notification (with summary text)
76
+ * @param text - Summary text to send (e.g., first 30 characters)
77
+ * @param pushText - Push notification message (e.g., "任务已完成:xxx...")
78
+ */
79
+ async sendPush(text, pushText) {
80
+ if (!this.isConfigured()) {
81
+ console.log("[PUSH] Push not configured, skipping");
82
+ return false;
83
+ }
84
+ try {
85
+ const timestamp = Date.now().toString();
86
+ const signature = this.generateSignature(timestamp);
87
+ const messageId = this.generateUUID();
88
+ const payload = {
89
+ jsonrpc: "2.0",
90
+ id: messageId,
91
+ result: {
92
+ id: this.generateUUID(),
93
+ apiId: this.config.apiId,
94
+ pushId: this.config.pushId,
95
+ pushText: pushText,
96
+ kind: "task",
97
+ artifacts: [{
98
+ artifactId: this.generateUUID(),
99
+ parts: [{
100
+ kind: "text",
101
+ text: text, // Summary text
102
+ }]
103
+ }],
104
+ status: { state: "completed" }
105
+ }
106
+ };
107
+ console.log(`[PUSH] Sending push notification: ${pushText}`);
108
+ const response = await fetch(this.pushUrl, {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ "Accept": "application/json",
113
+ "x-hag-trace-id": this.generateUUID(),
114
+ "X-Access-Key": this.config.ak,
115
+ "X-Sign": signature,
116
+ "X-Ts": timestamp,
117
+ },
118
+ body: JSON.stringify(payload),
119
+ });
120
+ if (response.ok) {
121
+ console.log("[PUSH] Push notification sent successfully");
122
+ return true;
123
+ }
124
+ else {
125
+ console.error(`[PUSH] Failed: HTTP ${response.status}`);
126
+ return false;
127
+ }
128
+ }
129
+ catch (error) {
130
+ console.error("[PUSH] Error:", error);
131
+ return false;
132
+ }
133
+ }
134
+ }
135
+ exports.XiaoYiPushService = XiaoYiPushService;
package/dist/runtime.d.ts CHANGED
@@ -23,6 +23,11 @@ export declare class XiaoYiRuntime {
23
23
  private timeoutConfig;
24
24
  private sessionAbortControllerMap;
25
25
  private sessionActiveRunMap;
26
+ private sessionStartTimeMap;
27
+ private static readonly SESSION_STALE_TIMEOUT_MS;
28
+ private sessionTaskTimeoutMap;
29
+ private sessionPushPendingMap;
30
+ private taskTimeoutMs;
26
31
  constructor();
27
32
  getInstanceId(): string;
28
33
  /**
@@ -115,6 +120,7 @@ export declare class XiaoYiRuntime {
115
120
  } | null;
116
121
  /**
117
122
  * Check if a session has an active agent run
123
+ * If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
118
124
  * @param sessionId - Session ID
119
125
  * @returns true if session is busy
120
126
  */
@@ -140,6 +146,46 @@ export declare class XiaoYiRuntime {
140
146
  * Clear all AbortControllers
141
147
  */
142
148
  clearAllAbortControllers(): void;
149
+ /**
150
+ * Generate a composite key for session+task combination
151
+ * This ensures each task has its own push state, even within the same session
152
+ */
153
+ private getPushStateKey;
154
+ /**
155
+ * Set task timeout time (from configuration)
156
+ */
157
+ setTaskTimeout(timeoutMs: number): void;
158
+ /**
159
+ * Set a 1-hour task timeout timer for a session
160
+ * @returns timeout ID
161
+ */
162
+ setTaskTimeoutForSession(sessionId: string, taskId: string, callback: (sessionId: string, taskId: string) => void): NodeJS.Timeout;
163
+ /**
164
+ * Clear the task timeout timer for a session
165
+ */
166
+ clearTaskTimeoutForSession(sessionId: string): void;
167
+ /**
168
+ * Check if session+task is waiting for push notification
169
+ * @param sessionId - Session ID
170
+ * @param taskId - Task ID (optional, for per-task tracking)
171
+ */
172
+ isSessionWaitingForPush(sessionId: string, taskId?: string): boolean;
173
+ /**
174
+ * Mark session+task as waiting for push notification
175
+ * @param sessionId - Session ID
176
+ * @param taskId - Task ID (optional, for per-task tracking)
177
+ */
178
+ markSessionWaitingForPush(sessionId: string, taskId?: string): void;
179
+ /**
180
+ * Clear the waiting push state for a session+task
181
+ * @param sessionId - Session ID
182
+ * @param taskId - Task ID (optional, for per-task tracking)
183
+ */
184
+ clearSessionWaitingForPush(sessionId: string, taskId?: string): void;
185
+ /**
186
+ * Clear all task timeout related state for a session
187
+ */
188
+ clearTaskTimeoutState(sessionId: string): void;
143
189
  }
144
190
  export declare function getXiaoYiRuntime(): XiaoYiRuntime;
145
191
  export declare function setXiaoYiRuntime(runtime: any): void;
package/dist/runtime.js CHANGED
@@ -30,6 +30,12 @@ class XiaoYiRuntime {
30
30
  this.sessionAbortControllerMap = new Map();
31
31
  // Track if a session has an active agent run (for concurrent request detection)
32
32
  this.sessionActiveRunMap = new Map();
33
+ // Track session start time for timeout detection
34
+ this.sessionStartTimeMap = new Map();
35
+ // 1-hour task timeout mechanism
36
+ this.sessionTaskTimeoutMap = new Map();
37
+ this.sessionPushPendingMap = new Map();
38
+ this.taskTimeoutMs = 3600000; // Default 1 hour
33
39
  this.instanceId = `runtime_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
34
40
  console.log(`XiaoYi: Created new runtime instance: ${this.instanceId}`);
35
41
  }
@@ -106,6 +112,10 @@ class XiaoYiRuntime {
106
112
  this.clearAllTimeouts();
107
113
  // Clear all abort controllers
108
114
  this.clearAllAbortControllers();
115
+ // Clear all task timeout state
116
+ for (const sessionId of this.sessionTaskTimeoutMap.keys()) {
117
+ this.clearTaskTimeoutState(sessionId);
118
+ }
109
119
  }
110
120
  /**
111
121
  * Set timeout configuration
@@ -246,16 +256,35 @@ class XiaoYiRuntime {
246
256
  const controller = new AbortController();
247
257
  this.sessionAbortControllerMap.set(sessionId, controller);
248
258
  this.sessionActiveRunMap.set(sessionId, true);
259
+ this.sessionStartTimeMap.set(sessionId, Date.now());
249
260
  console.log(`[ABORT] Created AbortController for session ${sessionId}`);
250
261
  return { controller, signal: controller.signal };
251
262
  }
252
263
  /**
253
264
  * Check if a session has an active agent run
265
+ * If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
254
266
  * @param sessionId - Session ID
255
267
  * @returns true if session is busy
256
268
  */
257
269
  isSessionActive(sessionId) {
258
- return this.sessionActiveRunMap.get(sessionId) || false;
270
+ const isActive = this.sessionActiveRunMap.get(sessionId) || false;
271
+ if (isActive) {
272
+ // Check if the session has been active for too long
273
+ const startTime = this.sessionStartTimeMap.get(sessionId);
274
+ if (startTime) {
275
+ const elapsed = Date.now() - startTime;
276
+ if (elapsed > XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS) {
277
+ // Session is stale, auto-cleanup and return false
278
+ console.log(`[CONCURRENT] Session ${sessionId} is stale (active for ${elapsed}ms), auto-cleaning`);
279
+ this.clearAbortControllerForSession(sessionId);
280
+ this.clearTaskIdForSession(sessionId);
281
+ this.clearSessionTimeout(sessionId);
282
+ this.sessionStartTimeMap.delete(sessionId);
283
+ return false;
284
+ }
285
+ }
286
+ }
287
+ return isActive;
259
288
  }
260
289
  /**
261
290
  * Abort a session's agent run
@@ -294,6 +323,8 @@ class XiaoYiRuntime {
294
323
  }
295
324
  // Also clear the active run flag
296
325
  this.sessionActiveRunMap.delete(sessionId);
326
+ // Clear the session start time
327
+ this.sessionStartTimeMap.delete(sessionId);
297
328
  console.log(`[CONCURRENT] Session ${sessionId} marked as inactive`);
298
329
  }
299
330
  /**
@@ -303,8 +334,91 @@ class XiaoYiRuntime {
303
334
  this.sessionAbortControllerMap.clear();
304
335
  console.log("[ABORT] All AbortControllers cleared");
305
336
  }
337
+ // ==================== PUSH STATE MANAGEMENT HELPERS ====================
338
+ /**
339
+ * Generate a composite key for session+task combination
340
+ * This ensures each task has its own push state, even within the same session
341
+ */
342
+ getPushStateKey(sessionId, taskId) {
343
+ return `${sessionId}:${taskId}`;
344
+ }
345
+ // ==================== END PUSH STATE MANAGEMENT HELPERS ====================
346
+ // ==================== 1-HOUR TASK TIMEOUT METHODS ====================
347
+ /**
348
+ * Set task timeout time (from configuration)
349
+ */
350
+ setTaskTimeout(timeoutMs) {
351
+ this.taskTimeoutMs = timeoutMs;
352
+ console.log(`[TASK TIMEOUT] Task timeout set to ${timeoutMs}ms`);
353
+ }
354
+ /**
355
+ * Set a 1-hour task timeout timer for a session
356
+ * @returns timeout ID
357
+ */
358
+ setTaskTimeoutForSession(sessionId, taskId, callback) {
359
+ this.clearTaskTimeoutForSession(sessionId);
360
+ const timeoutId = setTimeout(() => {
361
+ console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms timeout triggered for session ${sessionId}, task ${taskId}`);
362
+ callback(sessionId, taskId);
363
+ }, this.taskTimeoutMs);
364
+ this.sessionTaskTimeoutMap.set(sessionId, timeoutId);
365
+ console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms task timeout started for session ${sessionId}`);
366
+ return timeoutId;
367
+ }
368
+ /**
369
+ * Clear the task timeout timer for a session
370
+ */
371
+ clearTaskTimeoutForSession(sessionId) {
372
+ const timeoutId = this.sessionTaskTimeoutMap.get(sessionId);
373
+ if (timeoutId) {
374
+ clearTimeout(timeoutId);
375
+ this.sessionTaskTimeoutMap.delete(sessionId);
376
+ console.log(`[TASK TIMEOUT] Timeout cleared for session ${sessionId}`);
377
+ }
378
+ }
379
+ /**
380
+ * Check if session+task is waiting for push notification
381
+ * @param sessionId - Session ID
382
+ * @param taskId - Task ID (optional, for per-task tracking)
383
+ */
384
+ isSessionWaitingForPush(sessionId, taskId) {
385
+ const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
386
+ return this.sessionPushPendingMap.get(key) === true;
387
+ }
388
+ /**
389
+ * Mark session+task as waiting for push notification
390
+ * @param sessionId - Session ID
391
+ * @param taskId - Task ID (optional, for per-task tracking)
392
+ */
393
+ markSessionWaitingForPush(sessionId, taskId) {
394
+ const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
395
+ this.sessionPushPendingMap.set(key, true);
396
+ const taskInfo = taskId ? `, task ${taskId}` : '';
397
+ console.log(`[PUSH] Session ${sessionId}${taskInfo} marked as waiting for push`);
398
+ }
399
+ /**
400
+ * Clear the waiting push state for a session+task
401
+ * @param sessionId - Session ID
402
+ * @param taskId - Task ID (optional, for per-task tracking)
403
+ */
404
+ clearSessionWaitingForPush(sessionId, taskId) {
405
+ const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
406
+ this.sessionPushPendingMap.delete(key);
407
+ const taskInfo = taskId ? `, task ${taskId}` : '';
408
+ console.log(`[PUSH] Session ${sessionId}${taskInfo} cleared from waiting for push`);
409
+ }
410
+ /**
411
+ * Clear all task timeout related state for a session
412
+ */
413
+ clearTaskTimeoutState(sessionId) {
414
+ this.clearTaskTimeoutForSession(sessionId);
415
+ this.clearSessionWaitingForPush(sessionId);
416
+ console.log(`[TASK TIMEOUT] All timeout state cleared for session ${sessionId}`);
417
+ }
306
418
  }
307
419
  exports.XiaoYiRuntime = XiaoYiRuntime;
420
+ // Maximum time a session can be active before we consider it stale (5 minutes)
421
+ XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS = 5 * 60 * 1000;
308
422
  // Global runtime instance - use global object to survive module reloads
309
423
  // CRITICAL: Use string key instead of Symbol to ensure consistency across module reloads
310
424
  const GLOBAL_KEY = '__xiaoyi_runtime_instance__';
package/dist/types.d.ts CHANGED
@@ -152,6 +152,15 @@ export interface XiaoYiChannelConfig {
152
152
  sk: string;
153
153
  agentId: string;
154
154
  enableStreaming?: boolean;
155
+ apiId?: string;
156
+ pushId?: string;
157
+ taskTimeoutMs?: number;
158
+ /**
159
+ * Session cleanup timeout in milliseconds
160
+ * When user clears context, old sessions are cleaned up after this timeout
161
+ * Default: 1 hour (60 * 60 * 1000)
162
+ */
163
+ sessionCleanupTimeoutMs?: number;
155
164
  }
156
165
  export interface AuthCredentials {
157
166
  ak: string;
@@ -176,6 +185,7 @@ export interface InternalWebSocketConfig {
176
185
  ak: string;
177
186
  sk: string;
178
187
  enableStreaming?: boolean;
188
+ sessionCleanupTimeoutMs?: number;
179
189
  }
180
190
  export type ServerId = 'server1' | 'server2';
181
191
  export interface ServerConnectionState {
@@ -184,3 +194,14 @@ export interface ServerConnectionState {
184
194
  lastHeartbeat: number;
185
195
  reconnectAttempts: number;
186
196
  }
197
+ /**
198
+ * Session cleanup state for delayed cleanup
199
+ */
200
+ export interface SessionCleanupState {
201
+ sessionId: string;
202
+ serverId: ServerId;
203
+ markedForCleanupAt: number;
204
+ cleanupTimeoutId?: NodeJS.Timeout;
205
+ reason: 'user_cleared' | 'timeout' | 'error';
206
+ accumulatedText?: string;
207
+ }
@@ -1,11 +1,13 @@
1
1
  import { EventEmitter } from "events";
2
- import { A2AResponseMessage, WebSocketConnectionState, XiaoYiChannelConfig, ServerId, ServerConnectionState } from "./types";
2
+ import { A2AResponseMessage, WebSocketConnectionState, XiaoYiChannelConfig, ServerId, ServerConnectionState, SessionCleanupState } from "./types";
3
3
  export declare class XiaoYiWebSocketManager extends EventEmitter {
4
4
  private ws1;
5
5
  private ws2;
6
6
  private state1;
7
7
  private state2;
8
8
  private sessionServerMap;
9
+ private sessionCleanupStateMap;
10
+ private static readonly DEFAULT_CLEANUP_TIMEOUT_MS;
9
11
  private auth;
10
12
  private config;
11
13
  private heartbeatTimeout1?;
@@ -176,4 +178,34 @@ export declare class XiaoYiWebSocketManager extends EventEmitter {
176
178
  * Remove session mapping
177
179
  */
178
180
  removeSession(sessionId: string): void;
181
+ /**
182
+ * Mark a session for delayed cleanup
183
+ * @param sessionId The session ID to mark for cleanup
184
+ * @param serverId The server ID associated with this session
185
+ * @param timeoutMs Timeout in milliseconds before forcing cleanup
186
+ */
187
+ private markSessionForCleanup;
188
+ /**
189
+ * Force cleanup a session immediately
190
+ * @param sessionId The session ID to cleanup
191
+ */
192
+ forceCleanupSession(sessionId: string): void;
193
+ /**
194
+ * Check if a session is pending cleanup
195
+ * @param sessionId The session ID to check
196
+ * @returns True if session is pending cleanup
197
+ */
198
+ isSessionPendingCleanup(sessionId: string): boolean;
199
+ /**
200
+ * Get cleanup state for a session
201
+ * @param sessionId The session ID to check
202
+ * @returns Cleanup state if exists, undefined otherwise
203
+ */
204
+ getSessionCleanupState(sessionId: string): SessionCleanupState | undefined;
205
+ /**
206
+ * Update accumulated text for a pending cleanup session
207
+ * @param sessionId The session ID
208
+ * @param text The accumulated text
209
+ */
210
+ updateAccumulatedTextForCleanup(sessionId: string, text: string): void;
179
211
  }
package/dist/websocket.js CHANGED
@@ -30,6 +30,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
30
30
  };
31
31
  // ==================== Session → Server Mapping ====================
32
32
  this.sessionServerMap = new Map();
33
+ // ==================== Session Cleanup State ====================
34
+ // Track sessions that are pending cleanup (user cleared context but task still running)
35
+ this.sessionCleanupStateMap = new Map();
33
36
  // ==================== Active Tasks ====================
34
37
  this.activeTasks = new Map();
35
38
  // Resolve configuration with defaults and backward compatibility
@@ -107,6 +110,7 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
107
110
  ak: userConfig.ak,
108
111
  sk: userConfig.sk,
109
112
  enableStreaming: userConfig.enableStreaming ?? true,
113
+ sessionCleanupTimeoutMs: userConfig.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS,
110
114
  };
111
115
  }
112
116
  /**
@@ -245,6 +249,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
245
249
  this.state2.ready = false;
246
250
  this.sessionServerMap.clear();
247
251
  this.activeTasks.clear();
252
+ // Cleanup session cleanup state map
253
+ for (const [sessionId, state] of this.sessionCleanupStateMap.entries()) {
254
+ if (state.cleanupTimeoutId) {
255
+ clearTimeout(state.cleanupTimeoutId);
256
+ }
257
+ }
258
+ this.sessionCleanupStateMap.clear();
248
259
  this.emit("disconnected");
249
260
  }
250
261
  /**
@@ -382,7 +393,14 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
382
393
  /**
383
394
  * Send A2A response message with automatic routing
384
395
  */
385
- async sendResponse(response, taskId, sessionId, isFinal = true, append = false) {
396
+ async sendResponse(response, taskId, sessionId, isFinal = true, append = true) {
397
+ // Check if session is pending cleanup
398
+ const cleanupState = this.sessionCleanupStateMap.get(sessionId);
399
+ if (cleanupState) {
400
+ // Session is pending cleanup, silently discard response
401
+ console.log(`[RESPONSE] Discarding response for pending cleanup session ${sessionId}`);
402
+ return;
403
+ }
386
404
  // Find which server this session belongs to
387
405
  const targetServer = this.sessionServerMap.get(sessionId);
388
406
  if (!targetServer) {
@@ -461,6 +479,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
461
479
  * This uses "status-update" event type which keeps the conversation active
462
480
  */
463
481
  async sendStatusUpdate(taskId, sessionId, message, targetServer) {
482
+ // Check if session is pending cleanup
483
+ const cleanupState = this.sessionCleanupStateMap.get(sessionId);
484
+ if (cleanupState) {
485
+ // Session is pending cleanup, silently discard status updates
486
+ console.log(`[STATUS] Discarding status update for pending cleanup session ${sessionId}`);
487
+ return;
488
+ }
464
489
  const serverId = targetServer || this.sessionServerMap.get(sessionId);
465
490
  if (!serverId) {
466
491
  console.error(`[STATUS] Unknown server for session ${sessionId}`);
@@ -591,8 +616,8 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
591
616
  id: message.id,
592
617
  serverId: sourceServer,
593
618
  });
594
- // Remove session mapping
595
- this.sessionServerMap.delete(sessionId);
619
+ // Mark session for cleanup instead of immediate deletion
620
+ this.markSessionForCleanup(sessionId, sourceServer, this.config.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS);
596
621
  }
597
622
  /**
598
623
  * Handle clear message (legacy format)
@@ -606,7 +631,8 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
606
631
  id: message.id,
607
632
  serverId: sourceServer,
608
633
  });
609
- this.sessionServerMap.delete(message.sessionId);
634
+ // Mark session for cleanup instead of immediate deletion
635
+ this.markSessionForCleanup(message.sessionId, sourceServer, this.config.sessionCleanupTimeoutMs ?? XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS);
610
636
  }
611
637
  /**
612
638
  * Handle tasks/cancel message
@@ -636,7 +662,7 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
636
662
  /**
637
663
  * Convert A2AResponseMessage to JSON-RPC 2.0 format
638
664
  */
639
- convertToJsonRpcFormat(response, taskId, isFinal = true, append = false) {
665
+ convertToJsonRpcFormat(response, taskId, isFinal = true, append = true) {
640
666
  const artifactId = `artifact_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
641
667
  if (response.status === "error" && response.error) {
642
668
  return {
@@ -650,9 +676,11 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
650
676
  }
651
677
  const parts = [];
652
678
  if (response.content.type === "text" && response.content.text) {
679
+ // When isFinal=true, use empty string for text (no content needed for final chunk)
680
+ const textContent = isFinal ? "" : response.content.text;
653
681
  parts.push({
654
682
  kind: "text",
655
- text: response.content.text,
683
+ text: textContent,
656
684
  });
657
685
  }
658
686
  else if (response.content.type === "file") {
@@ -665,10 +693,11 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
665
693
  },
666
694
  });
667
695
  }
696
+ // When isFinal=true, append should be true and text should be empty
668
697
  const artifactEvent = {
669
698
  taskId: taskId,
670
699
  kind: "artifact-update",
671
- append: append,
700
+ append: isFinal ? true : append,
672
701
  lastChunk: isFinal,
673
702
  final: isFinal,
674
703
  artifact: {
@@ -918,6 +947,86 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
918
947
  removeSession(sessionId) {
919
948
  this.sessionServerMap.delete(sessionId);
920
949
  }
950
+ /**
951
+ * Mark a session for delayed cleanup
952
+ * @param sessionId The session ID to mark for cleanup
953
+ * @param serverId The server ID associated with this session
954
+ * @param timeoutMs Timeout in milliseconds before forcing cleanup
955
+ */
956
+ markSessionForCleanup(sessionId, serverId, timeoutMs) {
957
+ // Check if already marked
958
+ const existingState = this.sessionCleanupStateMap.get(sessionId);
959
+ if (existingState) {
960
+ // Already pending cleanup, reset timeout
961
+ if (existingState.cleanupTimeoutId) {
962
+ clearTimeout(existingState.cleanupTimeoutId);
963
+ }
964
+ console.log(`[CLEANUP] Session ${sessionId} already pending cleanup, resetting timeout`);
965
+ }
966
+ // Create new cleanup state
967
+ const newState = {
968
+ sessionId,
969
+ serverId,
970
+ markedForCleanupAt: Date.now(),
971
+ reason: 'user_cleared',
972
+ };
973
+ // Start cleanup timeout
974
+ const timeoutId = setTimeout(() => {
975
+ console.log(`[CLEANUP] Timeout reached for session ${sessionId}, forcing cleanup`);
976
+ this.forceCleanupSession(sessionId);
977
+ }, timeoutMs);
978
+ newState.cleanupTimeoutId = timeoutId;
979
+ this.sessionCleanupStateMap.set(sessionId, newState);
980
+ console.log(`[CLEANUP] Session ${sessionId} marked for cleanup (timeout: ${timeoutMs}ms)`);
981
+ }
982
+ /**
983
+ * Force cleanup a session immediately
984
+ * @param sessionId The session ID to cleanup
985
+ */
986
+ forceCleanupSession(sessionId) {
987
+ // Check if already cleaned
988
+ const state = this.sessionCleanupStateMap.get(sessionId);
989
+ if (!state) {
990
+ console.log(`[CLEANUP] Session ${sessionId} already cleaned up, skipping`);
991
+ return;
992
+ }
993
+ // Clear timeout
994
+ if (state.cleanupTimeoutId) {
995
+ clearTimeout(state.cleanupTimeoutId);
996
+ }
997
+ // Remove from both maps
998
+ this.sessionServerMap.delete(sessionId);
999
+ this.sessionCleanupStateMap.delete(sessionId);
1000
+ console.log(`[CLEANUP] Session ${sessionId} cleanup completed`);
1001
+ }
1002
+ /**
1003
+ * Check if a session is pending cleanup
1004
+ * @param sessionId The session ID to check
1005
+ * @returns True if session is pending cleanup
1006
+ */
1007
+ isSessionPendingCleanup(sessionId) {
1008
+ return this.sessionCleanupStateMap.has(sessionId);
1009
+ }
1010
+ /**
1011
+ * Get cleanup state for a session
1012
+ * @param sessionId The session ID to check
1013
+ * @returns Cleanup state if exists, undefined otherwise
1014
+ */
1015
+ getSessionCleanupState(sessionId) {
1016
+ return this.sessionCleanupStateMap.get(sessionId);
1017
+ }
1018
+ /**
1019
+ * Update accumulated text for a pending cleanup session
1020
+ * @param sessionId The session ID
1021
+ * @param text The accumulated text
1022
+ */
1023
+ updateAccumulatedTextForCleanup(sessionId, text) {
1024
+ const state = this.sessionCleanupStateMap.get(sessionId);
1025
+ if (state) {
1026
+ state.accumulatedText = text;
1027
+ }
1028
+ }
921
1029
  }
922
1030
  exports.XiaoYiWebSocketManager = XiaoYiWebSocketManager;
1031
+ XiaoYiWebSocketManager.DEFAULT_CLEANUP_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
923
1032
  XiaoYiWebSocketManager.STABLE_CONNECTION_THRESHOLD = 10000; // 10 seconds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi",
3
- "version": "2.5.5",
3
+ "version": "2.5.6",
4
4
  "description": "XiaoYi channel plugin for OpenClaw",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -51,6 +51,7 @@
51
51
  "devDependencies": {
52
52
  "@types/node": "^20.11.0",
53
53
  "@types/ws": "^8.5.10",
54
+ "openclaw": "^2026.2.24",
54
55
  "typescript": "^5.3.3"
55
56
  },
56
57
  "files": [