@ynhcj/xiaoyi-channel 0.0.134-beta → 0.0.136-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.
Files changed (45) hide show
  1. package/dist/index.js +6 -40
  2. package/dist/src/bot.js +27 -37
  3. package/dist/src/cspl/call-api.d.ts +6 -0
  4. package/dist/src/cspl/call-api.js +48 -0
  5. package/dist/src/cspl/config.d.ts +11 -1
  6. package/dist/src/cspl/config.js +30 -0
  7. package/dist/src/cspl/middleware.d.ts +8 -0
  8. package/dist/src/cspl/middleware.js +87 -0
  9. package/dist/src/login-token-handler.js +8 -4
  10. package/dist/src/reply-dispatcher.d.ts +3 -1
  11. package/dist/src/reply-dispatcher.js +25 -22
  12. package/dist/src/task-manager.d.ts +4 -27
  13. package/dist/src/task-manager.js +13 -78
  14. package/dist/src/tools/calendar-tool.js +5 -1
  15. package/dist/src/tools/call-phone-tool.js +5 -1
  16. package/dist/src/tools/create-alarm-tool.js +5 -1
  17. package/dist/src/tools/delete-alarm-tool.js +5 -1
  18. package/dist/src/tools/location-tool.js +5 -1
  19. package/dist/src/tools/login-token-tool.js +13 -2
  20. package/dist/src/tools/modify-alarm-tool.js +5 -1
  21. package/dist/src/tools/modify-note-tool.js +5 -1
  22. package/dist/src/tools/note-tool.js +5 -1
  23. package/dist/src/tools/query-app-message-tool.js +5 -1
  24. package/dist/src/tools/query-memory-data-tool.js +5 -1
  25. package/dist/src/tools/query-todo-task-tool.js +5 -1
  26. package/dist/src/tools/save-file-to-phone-tool.js +5 -1
  27. package/dist/src/tools/save-media-to-gallery-tool.js +5 -1
  28. package/dist/src/tools/search-alarm-tool.js +5 -1
  29. package/dist/src/tools/search-calendar-tool.js +5 -1
  30. package/dist/src/tools/search-contact-tool.js +5 -1
  31. package/dist/src/tools/search-email-tool.js +5 -1
  32. package/dist/src/tools/search-file-tool.js +5 -1
  33. package/dist/src/tools/search-message-tool.js +5 -1
  34. package/dist/src/tools/search-note-tool.js +5 -1
  35. package/dist/src/tools/search-photo-gallery-tool.js +5 -1
  36. package/dist/src/tools/send-email-tool.js +5 -1
  37. package/dist/src/tools/send-message-tool.js +5 -1
  38. package/dist/src/tools/upload-file-tool.js +5 -1
  39. package/dist/src/tools/upload-photo-tool.js +5 -1
  40. package/dist/src/tools/xiaoyi-add-collection-tool.js +5 -1
  41. package/dist/src/tools/xiaoyi-collection-tool.js +5 -1
  42. package/dist/src/tools/xiaoyi-delete-collection-tool.js +5 -1
  43. package/dist/src/tools/xiaoyi-gui-tool.js +3 -1
  44. package/openclaw.plugin.json +3 -0
  45. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,12 +1,8 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
2
  import { xiaoyiProvider } from "./src/provider.js";
3
3
  import { xyPlugin } from "./src/channel.js";
4
- import { callCsplApi } from "./src/cspl/call-api.js";
5
- import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./src/cspl/constants.js";
6
- import { extractResultText, parseSecurityResult, processText, validateAndTruncateText, } from "./src/cspl/utils.js";
4
+ import { createCsplMiddleware } from "./src/cspl/middleware.js";
7
5
  import { setXYRuntime } from "./src/runtime.js";
8
- import { logger } from "./src/utils/logger.js";
9
- import { tryInjectSteer } from "./src/steer-injector.js";
10
6
  import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
11
7
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
12
8
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
@@ -23,41 +19,11 @@ function registerFullHooks(api) {
23
19
  const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
24
20
  api.on("before_prompt_build", beforePromptBuildHandler);
25
21
  registerSelfEvolutionToolResultNudge(api);
26
- api.on("after_tool_call", async (event, ctx) => {
27
- if (!ALLOWED_TOOLS.includes(event.toolName)) {
28
- return;
29
- }
30
- logger.log(`[SENTINEL HOOK] after_tool_call triggered: toolName=${event.toolName}, sessionKey=${ctx.sessionKey ?? "none"}`);
31
- try {
32
- const resultText = extractResultText(event, event.toolName);
33
- const resultLength = resultText.length;
34
- if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
35
- return;
36
- }
37
- const questionText = {
38
- subSceneID: "TOOL_OUTPUT",
39
- tool: event.toolName,
40
- output: [{ content: "" }],
41
- };
42
- const originText = processText(resultText);
43
- questionText.output[0].content = originText;
44
- let finalJson = JSON.stringify(questionText);
45
- if (finalJson.length > MAX_TEXT_LENGTH) {
46
- const diff = finalJson.length - MAX_TEXT_LENGTH;
47
- const { text: trimmed } = validateAndTruncateText(originText, MAX_TEXT_LENGTH - diff);
48
- questionText.output[0].content = trimmed;
49
- finalJson = JSON.stringify(questionText);
50
- }
51
- const response = await callCsplApi(finalJson, api.config);
52
- const result = parseSecurityResult(response);
53
- logger.log(`[SENTINEL HOOK] Security result: status=${result.status}`);
54
- if (result.status === "REJECT") {
55
- await tryInjectSteer(ctx.sessionKey, STEER_ABORT_MESSAGE);
56
- }
57
- }
58
- catch (err) {
59
- logger.error(`[SENTINEL HOOK] after_tool_call error: ${err}`);
60
- }
22
+ // CSPL security scanning via AgentToolResultMiddleware.
23
+ // Intercepts tool results BEFORE they reach the LLM, replacing them
24
+ // with a security rejection message when CSPL returns REJECT.
25
+ api.registerAgentToolResultMiddleware(createCsplMiddleware(), {
26
+ runtimes: ["pi"],
61
27
  });
62
28
  }
63
29
  export default definePluginEntry({
package/dist/src/bot.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { getXYRuntime } from "./runtime.js";
2
- import { setCachedContext } from "./steer-injector.js";
3
2
  import { createXYReplyDispatcher } from "./reply-dispatcher.js";
4
3
  import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractTriggerData } from "./parser.js";
5
4
  import { downloadFilesFromParts } from "./file-download.js";
@@ -13,7 +12,7 @@ import { getPushDataById } from "./utils/pushdata-manager.js";
13
12
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
13
  import { saveRuntimeInfo } from "./utils/runtime-manager.js";
15
14
  import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
16
- import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActiveTask, } from "./task-manager.js";
15
+ import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
17
16
  import { logger } from "./utils/logger.js";
18
17
  /**
19
18
  * Handle an incoming A2A message.
@@ -22,8 +21,6 @@ import { logger } from "./utils/logger.js";
22
21
  */
23
22
  export async function handleXYMessage(params) {
24
23
  const { cfg, runtime, message, accountId, webSocketSessionId } = params;
25
- // 每次收到消息时更新缓存,供 steer 注入使用
26
- setCachedContext(cfg, runtime, accountId);
27
24
  // Get runtime (already validated in monitor.ts, but get reference for use)
28
25
  const core = getXYRuntime();
29
26
  try {
@@ -99,22 +96,14 @@ export async function handleXYMessage(params) {
99
96
  }
100
97
  }
101
98
  // ========================================
102
- // 🔑 检测steer模式和是否是第二条消息
103
- const isSteerMode = cfg.messages?.queue?.mode === "steer";
104
- const isSecondMessage = isSteerMode && hasActiveTask(parsed.sessionId);
105
- if (isSecondMessage) {
106
- logger.log(`[BOT] 🔄 STEER MODE - Second message detected (will be follower)`);
99
+ // 🔑 注册taskId(检测是否是已有活跃任务的 session)
100
+ const isUpdate = hasActiveTask(parsed.sessionId);
101
+ if (isUpdate) {
102
+ logger.log(`[BOT] 🔄 STEER MODE - Second message detected (core will handle steer)`);
107
103
  logger.log(`[BOT] - Session: ${parsed.sessionId}`);
108
- logger.log(`[BOT] - New taskId: ${parsed.taskId} (will replace current)`);
109
- }
110
- // 🔑 注册taskId(第二条消息会覆盖第一条的taskId)
111
- const { isUpdate, refCount } = registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId, { incrementRef: true } // 增加引用计数
112
- );
113
- // 🔑 如果是第一条消息,锁定taskId防止被过早清理
114
- if (!isUpdate) {
115
- lockTaskId(parsed.sessionId);
116
- logger.log(`[BOT] 🔒 Locked taskId for first message`);
104
+ logger.log(`[BOT] - New taskId: ${parsed.taskId}`);
117
105
  }
106
+ registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId);
118
107
  // Extract and update push_id if present
119
108
  const pushId = extractPushId(parsed.parts);
120
109
  if (pushId) {
@@ -163,14 +152,14 @@ export async function handleXYMessage(params) {
163
152
  agentId: route.accountId,
164
153
  deviceType,
165
154
  });
166
- // 🔑 发送初始状态更新(第二条消息也要发,用新taskId)
155
+ // 🔑 发送初始状态更新
167
156
  logger.log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
168
157
  void sendStatusUpdate({
169
158
  config,
170
159
  sessionId: parsed.sessionId,
171
160
  taskId: parsed.taskId,
172
161
  messageId: parsed.messageId,
173
- text: isSecondMessage ? "新消息已接收,正在处理..." : "任务正在处理中,请稍候~",
162
+ text: "任务正在处理中,请稍候~",
174
163
  state: "working",
175
164
  }).catch((err) => {
176
165
  logger.error(`Failed to send initial status update:`, err);
@@ -197,6 +186,11 @@ export async function handleXYMessage(params) {
197
186
  logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
198
187
  }
199
188
  }
189
+ // 🔑 Steer消息加 /steer 前缀,触发core的 queueEmbeddedPiMessage
190
+ if (isUpdate && textForAgent) {
191
+ textForAgent = `/steer ${textForAgent}`;
192
+ logger.log(`[BOT] 🔄 Prepended /steer for steer injection`);
193
+ }
200
194
  const fileParts = extractFileParts(parsed.parts);
201
195
  // Download files to local disk
202
196
  const downloadedFiles = await downloadFilesFromParts(fileParts);
@@ -242,7 +236,11 @@ export async function handleXYMessage(params) {
242
236
  ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
243
237
  ...mediaPayload,
244
238
  });
245
- // 🔑 创建dispatcher(dispatcher会自动使用动态taskId)
239
+ // 🔑 Dynamic steer state: when isUpdate (second message), start as steered=true
240
+ // so the dispatcher skips all user-facing callbacks (deliver, onIdle, etc.)
241
+ // and onSettled skips cleanup.
242
+ const steerState = { steered: isUpdate };
243
+ // 🔑 创建dispatcher
246
244
  logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
247
245
  logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
248
246
  const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
@@ -252,14 +250,9 @@ export async function handleXYMessage(params) {
252
250
  taskId: parsed.taskId,
253
251
  messageId: parsed.messageId,
254
252
  accountId: route.accountId,
255
- isSteerFollower: isSecondMessage, // 🔑 标记第二条消息
253
+ steerState,
256
254
  });
257
- // 🔑 只有第一条消息启动状态定时器
258
- // 第二条消息会很快返回,不需要定时器
259
- if (!isSecondMessage) {
260
- startStatusInterval();
261
- logger.log(`[BOT-DISPATCHER] ✅ Status interval started for first message`);
262
- }
255
+ startStatusInterval();
263
256
  // Build session context for AsyncLocalStorage
264
257
  const sessionContext = {
265
258
  config,
@@ -274,15 +267,13 @@ export async function handleXYMessage(params) {
274
267
  dispatcher,
275
268
  onSettled: () => {
276
269
  logger.log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
277
- logger.log(`[BOT] - isSecondMessage: ${isSecondMessage}`);
278
- // 🔑 减少引用计数
279
- decrementTaskIdRef(parsed.sessionId);
280
- // 🔑 如果是第一条消息完成,解锁
281
- if (!isSecondMessage) {
282
- unlockTaskId(parsed.sessionId);
283
- logger.log(`[BOT] 🔓 Unlocked taskId (first message completed)`);
270
+ logger.log(`[BOT] - steered: ${steerState.steered}`);
271
+ // 🔑 When steered, skip heavy cleanup — the first message's dispatcher is still running
272
+ if (steerState.steered) {
273
+ logger.log(`[BOT] Steered dispatch settled (skipping cleanup)`);
274
+ return;
284
275
  }
285
- // 减少session引用计数
276
+ decrementTaskIdRef(parsed.sessionId);
286
277
  unregisterSession(route.sessionKey);
287
278
  logger.log(`[BOT] ✅ Cleanup completed`);
288
279
  },
@@ -339,7 +330,6 @@ export async function handleXYMessage(params) {
339
330
  logger.log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
340
331
  // 清理 taskId
341
332
  decrementTaskIdRef(sessionId);
342
- unlockTaskId(sessionId);
343
333
  // 清理 session
344
334
  const core = getXYRuntime();
345
335
  const route = core.channel.routing.resolveAgentRoute({
@@ -1,3 +1,9 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { type CsplConfig } from "./config.js";
2
3
  import type { ApiResponse } from "./constants.js";
3
4
  export declare function callCsplApi(questionText: string, cfg: ClawdbotConfig): Promise<ApiResponse>;
5
+ /**
6
+ * Call CSPL API with a pre-resolved CsplConfig.
7
+ * Used by AgentToolResultMiddleware which doesn't have ClawdbotConfig.
8
+ */
9
+ export declare function callCsplApiWithConfig(questionText: string, config: CsplConfig): Promise<ApiResponse>;
@@ -88,3 +88,51 @@ export async function callCsplApi(questionText, cfg) {
88
88
  req.end();
89
89
  });
90
90
  }
91
+ /**
92
+ * Call CSPL API with a pre-resolved CsplConfig.
93
+ * Used by AgentToolResultMiddleware which doesn't have ClawdbotConfig.
94
+ */
95
+ export async function callCsplApiWithConfig(questionText, config) {
96
+ const headers = buildHeaders(config);
97
+ const payload = {
98
+ questionText,
99
+ textSource: config.textSource,
100
+ action: config.action,
101
+ extra: JSON.stringify({ userId: config.uid }),
102
+ };
103
+ return new Promise((resolve, reject) => {
104
+ const options = buildRequestOptions(config.api.url, headers, config.api.timeout);
105
+ const req = https.request(options, (res) => {
106
+ if (res.statusCode && res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
107
+ reject(new Error(`[SENTINEL HOOK] HTTP error: ${res.statusCode}`));
108
+ return;
109
+ }
110
+ let data = "";
111
+ res.on("data", (chunk) => {
112
+ data += chunk;
113
+ });
114
+ res.on("end", () => {
115
+ try {
116
+ const result = parseResponse(data);
117
+ logger.log(`[SENTINEL HOOK] ✅ 请求成功`);
118
+ resolve(result);
119
+ }
120
+ catch (e) {
121
+ logger.error(`[SENTINEL HOOK] ❌ 请求失败: ${e instanceof Error ? e.message : String(e)}`);
122
+ reject(e);
123
+ }
124
+ });
125
+ });
126
+ req.on("error", (error) => {
127
+ logger.error(`[SENTINEL HOOK] ❌ 请求错误: ${error instanceof Error ? error.message : String(error)}`);
128
+ reject(error);
129
+ });
130
+ req.on("timeout", () => {
131
+ logger.error(`[SENTINEL HOOK] ⏰ 请求超时 (${config.api.timeout}ms)`);
132
+ req.destroy();
133
+ reject(new Error("[SENTINEL HOOK] Request timeout"));
134
+ });
135
+ req.write(JSON.stringify(payload));
136
+ req.end();
137
+ });
138
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import type { XYChannelConfig } from "../types.js";
2
3
  export interface ApiConfig {
3
4
  url: string;
4
5
  timeout: number;
@@ -15,5 +16,14 @@ export interface CsplConfig {
15
16
  /**
16
17
  * 构建 CSPL 配置。uid 和 apiKey 复用 XYChannelConfig,避免重复配置。
17
18
  * serviceUrl 从 .xiaoyienv 文件读取,skillId 写死在常量中。
19
+ *
20
+ * Accepts either ClawdbotConfig (legacy after_tool_call path) or
21
+ * XYChannelConfig (AgentToolResultMiddleware path). Config is cached
22
+ * after the first successful call so subsequent calls can omit the arg.
18
23
  */
19
- export declare function getCsplConfig(cfg: ClawdbotConfig): CsplConfig;
24
+ export declare function getCsplConfig(cfg?: ClawdbotConfig): CsplConfig;
25
+ /**
26
+ * Initialize CSPL config from an already-resolved XYChannelConfig.
27
+ * Used by AgentToolResultMiddleware which has session context but not ClawdbotConfig.
28
+ */
29
+ export declare function initCsplConfigFromXYConfig(xyConfig: XYChannelConfig): CsplConfig;
@@ -27,10 +27,17 @@ function readServiceUrl() {
27
27
  /**
28
28
  * 构建 CSPL 配置。uid 和 apiKey 复用 XYChannelConfig,避免重复配置。
29
29
  * serviceUrl 从 .xiaoyienv 文件读取,skillId 写死在常量中。
30
+ *
31
+ * Accepts either ClawdbotConfig (legacy after_tool_call path) or
32
+ * XYChannelConfig (AgentToolResultMiddleware path). Config is cached
33
+ * after the first successful call so subsequent calls can omit the arg.
30
34
  */
31
35
  export function getCsplConfig(cfg) {
32
36
  if (cachedConfig)
33
37
  return cachedConfig;
38
+ if (!cfg) {
39
+ throw new Error("[SENTINEL HOOK] CSPL config not initialized: pass ClawdbotConfig on first call");
40
+ }
34
41
  const xyConfig = resolveXYConfig(cfg);
35
42
  const serviceUrl = readServiceUrl();
36
43
  cachedConfig = {
@@ -48,3 +55,26 @@ export function getCsplConfig(cfg) {
48
55
  logger.log("[SENTINEL HOOK] Config loaded (uid/apiKey from XYChannelConfig)");
49
56
  return cachedConfig;
50
57
  }
58
+ /**
59
+ * Initialize CSPL config from an already-resolved XYChannelConfig.
60
+ * Used by AgentToolResultMiddleware which has session context but not ClawdbotConfig.
61
+ */
62
+ export function initCsplConfigFromXYConfig(xyConfig) {
63
+ if (cachedConfig)
64
+ return cachedConfig;
65
+ const serviceUrl = readServiceUrl();
66
+ cachedConfig = {
67
+ api: {
68
+ url: `${serviceUrl}${API_URL_SUFFIX}`,
69
+ timeout: CSPL_STATIC_CONFIG.api.timeout,
70
+ },
71
+ uid: xyConfig.uid,
72
+ apiKey: xyConfig.apiKey,
73
+ skillId: CSPL_STATIC_CONFIG.skillId,
74
+ requestFrom: CSPL_STATIC_CONFIG.requestFrom,
75
+ textSource: CSPL_STATIC_CONFIG.textSource,
76
+ action: CSPL_STATIC_CONFIG.action,
77
+ };
78
+ logger.log("[SENTINEL HOOK] Config loaded via XYChannelConfig");
79
+ return cachedConfig;
80
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentToolResultMiddleware } from "openclaw/plugin-sdk/agent-harness-runtime";
2
+ /**
3
+ * Create the CSPL AgentToolResultMiddleware.
4
+ *
5
+ * Gets XYChannelConfig from session context (via sessionKey) to initialize
6
+ * the CSPL API config on first call, then caches it for subsequent calls.
7
+ */
8
+ export declare function createCsplMiddleware(): AgentToolResultMiddleware;
@@ -0,0 +1,87 @@
1
+ // CSPL AgentToolResultMiddleware
2
+ // Replaces the after_tool_call hook with a middleware that intercepts tool results
3
+ // BEFORE they reach the LLM, enabling true security interruption.
4
+ import { callCsplApiWithConfig } from "./call-api.js";
5
+ import { getCsplConfig, initCsplConfigFromXYConfig } from "./config.js";
6
+ import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./constants.js";
7
+ import { parseSecurityResult, processText, validateAndTruncateText, } from "./utils.js";
8
+ import { getSessionContext } from "../tools/session-manager.js";
9
+ import { logger } from "../utils/logger.js";
10
+ /**
11
+ * Extract text content from an OpenClawAgentToolResult.
12
+ */
13
+ function extractMiddlewareResultText(event) {
14
+ const result = event.result;
15
+ if (!result?.content || !Array.isArray(result.content)) {
16
+ return "";
17
+ }
18
+ const texts = [];
19
+ // Special handling for web_fetch: text is in details.text
20
+ if (event.toolName === "web_fetch" && result.details?.text) {
21
+ texts.push(String(result.details.text));
22
+ }
23
+ else {
24
+ for (const item of result.content) {
25
+ if (item?.type === "text" && typeof item.text === "string") {
26
+ texts.push(item.text);
27
+ }
28
+ }
29
+ }
30
+ return texts.length > 0 ? texts.join("; ") : "";
31
+ }
32
+ /**
33
+ * Create the CSPL AgentToolResultMiddleware.
34
+ *
35
+ * Gets XYChannelConfig from session context (via sessionKey) to initialize
36
+ * the CSPL API config on first call, then caches it for subsequent calls.
37
+ */
38
+ export function createCsplMiddleware() {
39
+ return async (event, ctx) => {
40
+ if (!ALLOWED_TOOLS.includes(event.toolName)) {
41
+ return;
42
+ }
43
+ try {
44
+ const resultText = extractMiddlewareResultText(event);
45
+ const resultLength = resultText.length;
46
+ if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
47
+ return;
48
+ }
49
+ // Build CSPL request payload
50
+ const questionText = {
51
+ subSceneID: "TOOL_OUTPUT",
52
+ tool: event.toolName,
53
+ output: [{ content: "" }],
54
+ };
55
+ const originText = processText(resultText);
56
+ questionText.output[0].content = originText;
57
+ let finalJson = JSON.stringify(questionText);
58
+ if (finalJson.length > MAX_TEXT_LENGTH) {
59
+ const diff = finalJson.length - MAX_TEXT_LENGTH;
60
+ const { text: trimmed } = validateAndTruncateText(originText, MAX_TEXT_LENGTH - diff);
61
+ questionText.output[0].content = trimmed;
62
+ finalJson = JSON.stringify(questionText);
63
+ }
64
+ // Get CSPL config (cached after first call)
65
+ // Try session context first (XYChannelConfig), then fall back to cached config
66
+ const sessionCtx = getSessionContext(ctx.sessionKey ?? "");
67
+ const csplConfig = sessionCtx
68
+ ? initCsplConfigFromXYConfig(sessionCtx.config)
69
+ : getCsplConfig();
70
+ const response = await callCsplApiWithConfig(finalJson, csplConfig);
71
+ const result = parseSecurityResult(response);
72
+ logger.log(`[CSPL MIDDLEWARE] Security result: status=${result.status}, toolName=${event.toolName}`);
73
+ if (result.status === "REJECT") {
74
+ logger.log(`[CSPL MIDDLEWARE] REJECT - replacing tool result with security message`);
75
+ return {
76
+ result: {
77
+ content: [{ type: "text", text: STEER_ABORT_MESSAGE }],
78
+ details: {},
79
+ },
80
+ };
81
+ }
82
+ }
83
+ catch (err) {
84
+ logger.error(`[CSPL MIDDLEWARE] Error: ${err}`);
85
+ }
86
+ };
87
+ }
@@ -14,11 +14,13 @@ const TOKEN_FILE_PATH = "/home/sandbox/.openclaw/.xiaoyitoken.json";
14
14
  export function handleLoginTokenEvent(context, runtime) {
15
15
  try {
16
16
  const clientId = context.event?.payload?.clientId;
17
+ const message = context.event?.payload?.message ?? "";
18
+ const code = context.event?.payload?.code ?? "";
17
19
  if (!clientId || typeof clientId !== "string") {
18
20
  logger.error("[LOGIN_TOKEN_HANDLER] invalid payload: missing clientId");
19
21
  return;
20
22
  }
21
- logger.log(`[LOGIN_TOKEN_HANDLER] received login token event, clientId=${clientId}`);
23
+ logger.log(`[LOGIN_TOKEN_HANDLER] received login token event, clientId=${clientId}, code=${code}`);
22
24
  // Ensure directory exists
23
25
  const dir = dirname(TOKEN_FILE_PATH);
24
26
  if (!existsSync(dir)) {
@@ -41,13 +43,15 @@ export function handleLoginTokenEvent(context, runtime) {
41
43
  const now = String(Date.now());
42
44
  const existing = tokens.find((t) => t.clientId === clientId);
43
45
  if (existing) {
44
- // Update timestamp
46
+ // Update timestamp, message, code
45
47
  existing.timestamp = now;
46
- logger.log(`[LOGIN_TOKEN_HANDLER] updated timestamp for clientId=${clientId}`);
48
+ existing.message = message;
49
+ existing.code = code;
50
+ logger.log(`[LOGIN_TOKEN_HANDLER] updated entry for clientId=${clientId}`);
47
51
  }
48
52
  else {
49
53
  // Insert new entry
50
- tokens.push({ clientId, timestamp: now });
54
+ tokens.push({ clientId, timestamp: now, message, code });
51
55
  logger.log(`[LOGIN_TOKEN_HANDLER] inserted new entry for clientId=${clientId}`);
52
56
  }
53
57
  writeFileSync(TOKEN_FILE_PATH, JSON.stringify(tokens, null, 2), "utf-8");
@@ -6,7 +6,9 @@ export interface CreateXYReplyDispatcherParams {
6
6
  taskId: string;
7
7
  messageId: string;
8
8
  accountId: string;
9
- isSteerFollower?: boolean;
9
+ steerState: {
10
+ steered: boolean;
11
+ };
10
12
  }
11
13
  /**
12
14
  * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
@@ -45,10 +45,9 @@ export async function cleanupStaleTempFiles(tempDir = "/tmp/xy_channel") {
45
45
  * Runtime is expected to be validated before calling this function.
46
46
  */
47
47
  export function createXYReplyDispatcher(params) {
48
- const { cfg, runtime, sessionId, taskId, messageId, accountId, isSteerFollower } = params;
48
+ const { cfg, runtime, sessionId, taskId, messageId, accountId, steerState } = params;
49
49
  logger.log(`[DISPATCHER-CREATE] ******* Creating dispatcher *******`);
50
50
  logger.log(`[DISPATCHER-CREATE] - taskId: ${taskId}`);
51
- logger.log(`[DISPATCHER-CREATE] - isSteerFollower: ${isSteerFollower ?? false}`);
52
51
  // 初始taskId和messageId(作为fallback)
53
52
  const initialTaskId = taskId;
54
53
  const initialMessageId = messageId;
@@ -111,9 +110,14 @@ export function createXYReplyDispatcher(params) {
111
110
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, accountId),
112
111
  onReplyStart: () => {
113
112
  const currentTaskId = getActiveTaskId();
114
- logger.log(`[REPLY START] Reply started for session ${sessionId}, taskId=${currentTaskId}, isSteerFollower=${isSteerFollower}`);
113
+ logger.log(`[REPLY START] Reply started for session ${sessionId}, taskId=${currentTaskId}, steered=${steerState.steered}`);
115
114
  },
116
115
  deliver: async (payload, info) => {
116
+ // 🔑 steered dispatch不发送内容(让主dispatcher处理)
117
+ if (steerState.steered) {
118
+ logger.log(`[DELIVER] Steered dispatch - skipping deliver, info.kind=${info?.kind}`);
119
+ return;
120
+ }
117
121
  const text = payload.text ?? "";
118
122
  const currentTaskId = getActiveTaskId();
119
123
  const currentMessageId = getActiveMessageId();
@@ -143,9 +147,9 @@ export function createXYReplyDispatcher(params) {
143
147
  onError: async (err, info) => {
144
148
  runtime.error?.(`xy: ${info.kind} reply failed: ${String(err)}`);
145
149
  stopStatusInterval();
146
- // 🔑 steer follower不发送错误状态(让主dispatcher处理)
147
- if (isSteerFollower) {
148
- logger.log(`[ON_ERROR] Steer follower - skipping error response`);
150
+ // 🔑 steered dispatcher不发送错误状态(让主dispatcher处理)
151
+ if (steerState.steered) {
152
+ logger.log(`[ON_ERROR] Steered dispatch - skipping error response`);
149
153
  return;
150
154
  }
151
155
  if (!hasSentResponse) {
@@ -172,17 +176,16 @@ export function createXYReplyDispatcher(params) {
172
176
  logger.log(`[ON_IDLE] Reply idle`);
173
177
  logger.log(`[ON_IDLE] - sessionId: ${sessionId}`);
174
178
  logger.log(`[ON_IDLE] - taskId: ${currentTaskId}`);
175
- logger.log(`[ON_IDLE] - isSteerFollower: ${isSteerFollower}`);
179
+ logger.log(`[ON_IDLE] - steered: ${steerState.steered}`);
176
180
  logger.log(`[ON_IDLE] - hasSentResponse: ${hasSentResponse}`);
177
181
  logger.log(`[ON_IDLE] - finalSent: ${finalSent}`);
178
- // 🔑 核心改动:steer follower不发送final响应
179
- if (isSteerFollower) {
180
- logger.log(`[ON_IDLE] Steer follower - skipping final response`);
181
- logger.log(`[ON_IDLE] - Message queued successfully, waiting for primary dispatcher`);
182
+ // 🔑 steered dispatch不发送final响应(核心已注入到活跃 Pi run)
183
+ if (steerState.steered) {
184
+ logger.log(`[ON_IDLE] Steered dispatch - skipping final response`);
182
185
  stopStatusInterval();
183
186
  return; // ← 直接返回,不发送任何东西!
184
187
  }
185
- // 正常模式(或steer的第一条消息)
188
+ // 正常模式(或未被steer的dispatch)
186
189
  if (hasSentResponse && !finalSent) {
187
190
  logger.log(`[ON_IDLE] Sending accumulated text, length=${accumulatedText.length}`);
188
191
  try {
@@ -214,7 +217,7 @@ export function createXYReplyDispatcher(params) {
214
217
  }
215
218
  }
216
219
  else {
217
- // 正常失败场景(非steer follower
220
+ // 正常失败场景(非steered
218
221
  logger.log(`[ON_IDLE] Skipping final message: hasSentResponse=${hasSentResponse}, finalSent=${finalSent}`);
219
222
  try {
220
223
  await sendStatusUpdate({
@@ -248,7 +251,7 @@ export function createXYReplyDispatcher(params) {
248
251
  },
249
252
  onCleanup: () => {
250
253
  const currentTaskId = getActiveTaskId();
251
- logger.log(`[ON_CLEANUP] Reply cleanup, taskId=${currentTaskId}, isSteerFollower=${isSteerFollower}`);
254
+ logger.log(`[ON_CLEANUP] Reply cleanup, taskId=${currentTaskId}, steered=${steerState.steered}`);
252
255
  },
253
256
  });
254
257
  return {
@@ -257,8 +260,8 @@ export function createXYReplyDispatcher(params) {
257
260
  ...replyOptions,
258
261
  onModelSelected: prefixContext.onModelSelected,
259
262
  onToolStart: async ({ name, phase }) => {
260
- // 🔑 steer follower不发送tool状态(让主dispatcher处理)
261
- if (isSteerFollower) {
263
+ // 🔑 steered dispatch不发送tool状态(让主dispatcher处理)
264
+ if (steerState.steered) {
262
265
  return;
263
266
  }
264
267
  const currentTaskId = getActiveTaskId();
@@ -289,8 +292,8 @@ export function createXYReplyDispatcher(params) {
289
292
  }
290
293
  },
291
294
  onToolResult: async (payload) => {
292
- // 🔑 steer follower不发送tool结果(让主dispatcher处理)
293
- if (isSteerFollower) {
295
+ // 🔑 steered dispatch不发送tool结果(让主dispatcher处理)
296
+ if (steerState.steered) {
294
297
  return;
295
298
  }
296
299
  const currentTaskId = getActiveTaskId();
@@ -317,8 +320,8 @@ export function createXYReplyDispatcher(params) {
317
320
  }
318
321
  },
319
322
  onReasoningStream: async (payload) => {
320
- // 🔑 steer follower不发送reasoning stream
321
- if (isSteerFollower) {
323
+ // 🔑 steered dispatch不发送reasoning stream
324
+ if (steerState.steered) {
322
325
  return;
323
326
  }
324
327
  const text = payload.text ?? "";
@@ -327,8 +330,8 @@ export function createXYReplyDispatcher(params) {
327
330
  // 如果需要可以启用
328
331
  },
329
332
  onPartialReply: async (payload) => {
330
- // 🔑 steer follower不发送partial reply(让主dispatcher处理)
331
- if (isSteerFollower) {
333
+ // 🔑 steered dispatch不发送partial reply(让主dispatcher处理)
334
+ if (steerState.steered) {
332
335
  return;
333
336
  }
334
337
  const currentTaskId = getActiveTaskId();
@@ -2,36 +2,17 @@ interface TaskIdBinding {
2
2
  sessionId: string;
3
3
  currentTaskId: string;
4
4
  currentMessageId: string;
5
- refCount: number;
6
5
  updatedAt: number;
7
- locked: boolean;
8
6
  }
9
7
  /**
10
- * 注册或更新session的活跃taskId
11
- * 返回是否是更新(用于判断是否是第二条消息)
8
+ * 注册或更新session的活跃taskId
9
+ * Returns true if this was an update (session already had an active task).
12
10
  */
13
- export declare function registerTaskId(sessionId: string, taskId: string, messageId: string, options?: {
14
- incrementRef?: boolean;
15
- }): {
16
- isUpdate: boolean;
17
- refCount: number;
18
- };
11
+ export declare function registerTaskId(sessionId: string, taskId: string, messageId: string): boolean;
19
12
  /**
20
- * 增加引用计数(消息开始处理时调用)
21
- */
22
- export declare function incrementTaskIdRef(sessionId: string): void;
23
- /**
24
- * 减少引用计数,当refCount=0时才真正清理
13
+ * 移除session的活跃taskId(消息处理完成时调用)。
25
14
  */
26
15
  export declare function decrementTaskIdRef(sessionId: string): void;
27
- /**
28
- * 锁定taskId,防止被清理(第一个消息使用)
29
- */
30
- export declare function lockTaskId(sessionId: string): void;
31
- /**
32
- * 解锁taskId(第一个消息完成时使用)
33
- */
34
- export declare function unlockTaskId(sessionId: string): void;
35
16
  /**
36
17
  * 获取session的当前活跃taskId
37
18
  */
@@ -44,10 +25,6 @@ export declare function getCurrentMessageId(sessionId: string): string | null;
44
25
  * 检查session是否有活跃的taskId
45
26
  */
46
27
  export declare function hasActiveTask(sessionId: string): boolean;
47
- /**
48
- * 获取完整的binding信息(用于调试)
49
- */
50
- export declare function getTaskIdBinding(sessionId: string): TaskIdBinding | null;
51
28
  /**
52
29
  * 获取所有活跃的 task bindings(用于 gateway_stop 通知等场景)
53
30
  */