@ynhcj/xiaoyi-channel 0.0.223-next → 0.0.225-next

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/index.js CHANGED
@@ -306,6 +306,53 @@ function registerCronRecoveryHook(api) {
306
306
  logger.log(`${logTag} ═══════════════════════════════════════════`);
307
307
  });
308
308
  }
309
+ /**
310
+ * 从 sessions_spawn 工具结果中提取 ACP child sessionKey。
311
+ * 兼容三种 result 形态:{status, childSessionKey} 直返、JSON 字符串、
312
+ * {content:[{text: "<json>"}], details:{...}} 工具信封。
313
+ * 仅接受 status="accepted" 且 key 形如 agent:<id>:acp:<uuid> 的结果。
314
+ */
315
+ function extractAcceptedAcpChildSessionKey(result) {
316
+ const pick = (obj) => {
317
+ if (!obj || typeof obj !== "object")
318
+ return null;
319
+ const record = obj;
320
+ if (record.status !== "accepted")
321
+ return null;
322
+ const key = record.childSessionKey;
323
+ return typeof key === "string" && key.includes(":acp:") ? key : null;
324
+ };
325
+ const direct = pick(result);
326
+ if (direct)
327
+ return direct;
328
+ const envelope = result;
329
+ const fromDetails = pick(envelope?.details);
330
+ if (fromDetails)
331
+ return fromDetails;
332
+ if (Array.isArray(envelope?.content)) {
333
+ for (const item of envelope.content) {
334
+ if (typeof item?.text !== "string")
335
+ continue;
336
+ try {
337
+ const fromText = pick(JSON.parse(item.text));
338
+ if (fromText)
339
+ return fromText;
340
+ }
341
+ catch {
342
+ // 非 JSON 文本,忽略
343
+ }
344
+ }
345
+ }
346
+ if (typeof result === "string") {
347
+ try {
348
+ return pick(JSON.parse(result));
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ }
354
+ return null;
355
+ }
309
356
  function registerSubagentHooks(api) {
310
357
  // subagent_spawned: fires after a subagent run is successfully registered.
311
358
  // We increment the expected completion count so that onIdle knows to wait.
@@ -318,6 +365,32 @@ function registerSubagentHooks(api) {
318
365
  logger.log(`[XY-SUBAGENT] spawned, requesterSessionKey=${requesterSessionKey.slice(0, 30)}, expected=${count}`);
319
366
  }
320
367
  });
368
+ // ACP SPAWN TRACKING: openclaw 的 subagent_spawned hook 仅由内嵌 subagent
369
+ // 路径(subagent-spawn.ts)发射;acp-spawn.ts(runtime="acp",如 Claude Code)
370
+ // 不发射 —— 实测 ACP run 仅在结束时经 run registry 触发 subagent_ended,
371
+ // 导致等待态从未建立、父 turn onIdle 直接发 final 结束对话。
372
+ // 因此在 after_tool_call 补记:sessions_spawn 成功且 childSessionKey 为 ACP
373
+ // 形态时递增等待计数。内嵌 subagent 的 childKey 形如 agent:<id>:subagent:<id>,
374
+ // 不命中此分支,仍由 subagent_spawned hook 负责,不会重复计数。
375
+ api.on("after_tool_call", async (event, ctx) => {
376
+ try {
377
+ if (event?.toolName !== "sessions_spawn" || event?.error)
378
+ return;
379
+ const childKey = extractAcceptedAcpChildSessionKey(event?.result);
380
+ if (!childKey)
381
+ return;
382
+ const sessionKey = ctx?.sessionKey;
383
+ if (!sessionKey)
384
+ return;
385
+ const count = markSubagentSpawned(sessionKey);
386
+ if (count > 0) {
387
+ logger.log(`[XY-SUBAGENT] ACP spawned, childSessionKey=${childKey.slice(0, 40)}, expected=${count}`);
388
+ }
389
+ }
390
+ catch (err) {
391
+ logger.error(`[XY-SUBAGENT] Error tracking ACP spawn:`, err);
392
+ }
393
+ });
321
394
  // subagent_ended: fires when a subagent run terminates (complete/error/killed).
322
395
  // This is the PRIMARY delivery tracking mechanism. When all expected
323
396
  // subagents have ended and parent has settled, we finalize the A2A session.
@@ -332,6 +405,10 @@ function registerSubagentHooks(api) {
332
405
  logger.log(`[XY-SUBAGENT-END] ended, targetSessionKey=${event?.targetSessionKey?.slice(0, 30)}, outcome=${event?.outcome}, complete=${transition?.isComplete ?? false}, shouldFinalize=${transition?.shouldFinalize ?? false}, transition=${!!transition}`);
333
406
  if (transition?.shouldFinalize) {
334
407
  logger.log(`[XY-SUBAGENT-END] Starting finalization...`);
408
+ // Grace: announce 投递的 sendText 与 subagent_ended hook 并发到达
409
+ // (实测 sendText 可能先于 hook 约 1s,也可能略晚于 hook),
410
+ // 短等让最后一条完成文本先落入 completionTexts 再合并发 final 帧。
411
+ await new Promise((r) => setTimeout(r, 1500));
335
412
  // 最终交付由对话管理层统一负责(含 config 解析、final 帧、清理)
336
413
  await deliverSubagentFinalResult({
337
414
  state: transition.state,
package/dist/src/bot.js CHANGED
@@ -17,7 +17,6 @@ import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
17
17
  import { setCsplSteerContext } from "./cspl/steer-context.js";
18
18
  import { resolveActiveEmbeddedRunSessionId, queueAgentHarnessMessage, } from "openclaw/plugin-sdk/agent-harness-runtime";
19
19
  import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
20
- import { registerSessionKeyMapping, getWaitState, hasWaitState, markParentSettled, cacheXYConfig, } from "./subagent-wait-state.js";
21
20
  import { logger } from "./utils/logger.js";
22
21
  /**
23
22
  * Handle an incoming A2A message.
@@ -166,7 +165,6 @@ export async function handleXYMessage(params) {
166
165
  const runCrossTaskContext = extractRunCrossTaskContext(parsed.parts);
167
166
  // Resolve configuration (needed for status updates)
168
167
  const config = resolveXYConfig(cfg);
169
- cacheXYConfig(config);
170
168
  // ✅ Resolve agent route (following feishu pattern)
171
169
  // accountId is "default" for XY (single account mode)
172
170
  // Use sessionId as peer.id to ensure all messages in the same session share context
@@ -180,16 +178,6 @@ export async function handleXYMessage(params) {
180
178
  },
181
179
  });
182
180
  log.log(`[BOT] Resolved route, sessionKey=${route.sessionKey}`);
183
- // Register sessionKey→A2A sessionId mapping for subagent hook translation.
184
- // Hooks receive openclaw sessionKey (e.g. "agent:main:direct:xxx") but
185
- // xy_channel operates on A2A sessionId. This bridge lets subagent_spawned/
186
- // subagent_ended hooks find the correct wait state.
187
- // IMPORTANT: Only register on the FIRST message. Steer messages have a
188
- // different taskId — overwriting the mapping would break subagent wait
189
- // state tracking for the original message's subagents.
190
- if (!isUpdate) {
191
- registerSessionKeyMapping(route.sessionKey, parsed.sessionId, parsed.taskId, parsed.messageId);
192
- }
193
181
  // Check for ACP runtime binding on this A2A conversation
194
182
  const runtimeRoute = resolveRuntimeConversationBindingRoute({
195
183
  route,
@@ -254,18 +242,20 @@ export async function handleXYMessage(params) {
254
242
  log.error(`[BOT] Failed to patch session model override:`, patchErr);
255
243
  }
256
244
  }
257
- // 🔑 发送初始状态更新
258
- log.log(`[BOT] Sending initial status update`);
259
- void sendStatusUpdate({
260
- config,
261
- sessionId: parsed.sessionId,
262
- taskId: parsed.taskId,
263
- messageId: parsed.messageId,
264
- text: "任务正在处理中,请稍候~",
265
- state: "working",
266
- }).catch((err) => {
267
- log.error(`Failed to send initial status update:`, err);
268
- });
245
+ // 🔑 发送初始状态更新(仅首条消息,steer 不重复发送)
246
+ if (!isUpdate) {
247
+ log.log(`[BOT] Sending initial status update`);
248
+ void sendStatusUpdate({
249
+ config,
250
+ sessionId: parsed.sessionId,
251
+ taskId: parsed.taskId,
252
+ messageId: parsed.messageId,
253
+ text: "任务正在处理中,请稍候~",
254
+ state: "working",
255
+ }).catch((err) => {
256
+ log.error(`Failed to send initial status update:`, err);
257
+ });
258
+ }
269
259
  }
270
260
  // Extract text and files from parts
271
261
  const text = extractTextFromParts(parsed.parts);
@@ -291,8 +281,10 @@ export async function handleXYMessage(params) {
291
281
  }
292
282
  }
293
283
  // ── Build message and dispatch via auto-reply pipeline ──────────
294
- // 首条消息和 steer 回退都走此统一 dispatch 路径。
295
- // File download — for both first message and steer fallthrough
284
+ // OpenClaw's auto-reply pipeline (src/auto-reply/reply/agent-runner.ts)
285
+ // detects active runs and handles steer injection natively — the channel
286
+ // does not need its own steer detection or polling logic.
287
+ // File download
296
288
  let mediaPayload = {};
297
289
  if (!skipReg || isUpdate) {
298
290
  const fileParts = extractFileParts(parsed.parts);
@@ -313,7 +305,7 @@ export async function handleXYMessage(params) {
313
305
  // per-session ReplyOperation lock in admitReplyTurn which would otherwise
314
306
  // block the steer message until the first message completes — making steer
315
307
  // indistinguishable from a followup.
316
- if (isUpdate && route.sessionKey) {
308
+ if (isUpdate && !skipReg && route.sessionKey) {
317
309
  const activeSessionId = resolveActiveEmbeddedRunSessionId(route.sessionKey);
318
310
  if (activeSessionId) {
319
311
  log.log(`[BOT-STEER] Direct steer attempt: activeSessionId=${activeSessionId}, textLen=${textForAgent.length}`);
@@ -322,10 +314,8 @@ export async function handleXYMessage(params) {
322
314
  });
323
315
  if (queued) {
324
316
  log.log(`[BOT-STEER] Direct steer succeeded — message injected into active run`);
325
- // Steer message taskId stays in the binding so getActiveTaskId()
326
- // resolves to the latest taskId for onPartialReply replies.
327
- // Cleanup happens via the original dispatcher's onIdleComplete →
328
- // decrementTaskIdRef(sessionId) which removes the entire binding.
317
+ // Steer message taskId refCount is no longer needed since we skip the dispatcher.
318
+ decrementTaskIdRef(parsed.sessionId);
329
319
  return;
330
320
  }
331
321
  log.log(`[BOT-STEER] Direct steer failed (queued=false), falling through to dispatchReplyFromConfig`);
@@ -337,8 +327,10 @@ export async function handleXYMessage(params) {
337
327
  // Resolve envelope format options (following feishu pattern)
338
328
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
339
329
  // Build message body with speaker prefix (following feishu pattern)
330
+ let messageBody = textForAgent;
331
+ // Add speaker prefix for clarity
340
332
  const speaker = parsed.sessionId;
341
- const messageBody = `${speaker}: ${textForAgent}`;
333
+ messageBody = `${speaker}: ${messageBody}`;
342
334
  // Format agent envelope (following feishu pattern)
343
335
  const body = core.channel.reply.formatAgentEnvelope({
344
336
  channel: "xiaoyi-channel",
@@ -397,15 +389,6 @@ export async function handleXYMessage(params) {
397
389
  cleaned = true;
398
390
  log.log(`[BOT] Cleanup started, steered=${steerState.steered}, skipReg=${skipReg}`);
399
391
  if (!skipReg) {
400
- // Check for pending subagent wait on this session
401
- const pendingWait = getWaitState(parsed.sessionId, parsed.taskId) ??
402
- (hasWaitState(parsed.sessionId) ? { deliveredCompletions: 0, expectedCompletions: 1 } : null);
403
- if (pendingWait && pendingWait.deliveredCompletions < pendingWait.expectedCompletions) {
404
- // Subagent wait active — skip cleanup, session stays alive
405
- log.log(`[BOT] Cleanup suppressed — subagent wait active on session, taskId=${parsed.taskId}`);
406
- cleaned = false;
407
- return;
408
- }
409
392
  decrementTaskIdRef(parsed.sessionId);
410
393
  }
411
394
  else {
@@ -447,34 +430,13 @@ export async function handleXYMessage(params) {
447
430
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
448
431
  await core.channel.reply.withReplyDispatcher({
449
432
  dispatcher,
450
- onSettled: async () => {
433
+ onSettled: () => {
451
434
  log.log(`[BOT] onSettled, steered=${steerState.steered}`);
452
435
  // 🔑 When steered, skip cleanup — the first message's dispatcher is still running
453
436
  if (steerState.steered) {
454
437
  log.log(`[BOT] Steered dispatch settled, skipping cleanup`);
455
438
  return;
456
439
  }
457
- // Subagent wait state: if parent has pending subagents, mark settled
458
- // and defer finalization. Cleanup is suppressed so the session stays alive.
459
- const pendingWait = getWaitState(parsed.sessionId, parsed.taskId);
460
- if (pendingWait && !pendingWait.parentSettled) {
461
- const transition = markParentSettled(parsed.sessionId, parsed.taskId);
462
- if (transition?.shouldFinalize) {
463
- // All completions arrived before parent settled → finalize now
464
- const { deliverSubagentFinalResult } = await import("./outbound.js");
465
- await deliverSubagentFinalResult({
466
- config,
467
- state: transition.state,
468
- reason: "all-subagent-results-delivered-before-parent-settled",
469
- });
470
- log.log(`[BOT] Subagent wait complete on parent settled; final response delivered`);
471
- }
472
- else {
473
- log.log(`[BOT] Subagent wait active, preserving session context for completion`);
474
- }
475
- dispatcherUpdaters.delete(parsed.sessionId);
476
- return;
477
- }
478
440
  // cleanup 已由 onIdleComplete 在 onIdle 的 finally 中执行。
479
441
  // onSettled 不做任何清理(直接在这里清理会发生 race condition)。
480
442
  dispatcherUpdaters.delete(parsed.sessionId);
@@ -486,9 +448,9 @@ export async function handleXYMessage(params) {
486
448
  // signal init complete to release the global dispatch gate
487
449
  // for the next session.
488
450
  const dispatchPromise = runWithSessionContext(sessionContext, async () => {
489
- const isSteerDispatch = isUpdate;
451
+ const isSteerDispatch = isUpdate && !skipReg;
490
452
  log.log(`[ALS-PROOF] bot entered dispatch scope sessionId=${sessionContext.sessionId} taskId=${sessionContext.taskId} isSteer=${isSteerDispatch}`);
491
- log.log(`[BOT-DISPATCH] dispatchReplyFromConfig starting, body.length=${ctxPayload.Body?.length ?? 0}`);
453
+ log.log(`[BOT-DISPATCH] dispatchReplyFromConfig starting, body.length=${ctxPayload.Body?.length ?? 0}, isSteer=${isSteerDispatch}`);
492
454
  try {
493
455
  const result = await core.channel.reply.dispatchReplyFromConfig({
494
456
  ctx: ctxPayload,
@@ -496,7 +458,13 @@ export async function handleXYMessage(params) {
496
458
  dispatcher,
497
459
  replyOptions,
498
460
  });
499
- log.log(`[BOT-DISPATCH] dispatchReplyFromConfig returned, result=${JSON.stringify(result)}`);
461
+ // 区分 steer 成功(undefined)和 steer 失败/正常 run(有结果)
462
+ if (result === undefined) {
463
+ log.log(`[BOT-STEER] dispatchReplyFromConfig returned undefined — steer injected via fast path OK, sessionKey=${route.sessionKey}`);
464
+ }
465
+ else {
466
+ log.log(`[BOT-DISPATCH] dispatchReplyFromConfig returned, resultType=${typeof result}, isSteer=${isSteerDispatch}, steered=${steerState.steered}`);
467
+ }
500
468
  return result;
501
469
  }
502
470
  catch (dispatchErr) {
@@ -547,21 +515,19 @@ export async function handleXYMessage(params) {
547
515
  function buildXYMediaPayload(mediaList) {
548
516
  const first = mediaList[0];
549
517
  const mediaPaths = mediaList.map((media) => media.path);
550
- const mediaUrls = mediaList.map((media) => media.uri);
551
518
  const mediaTypes = mediaList.map((media) => media.mimeType).filter(Boolean);
552
519
  return {
553
520
  MediaPath: first?.path,
554
521
  MediaType: first?.mimeType,
555
- MediaUrl: first?.uri,
556
522
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
557
- MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
558
523
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
559
524
  };
560
525
  }
561
526
  // ─────────────────────────────────────────────────────────────
562
527
  // Dispatcher updaters (cross-chain taskId bridging)
563
528
  // ─────────────────────────────────────────────────────────────
564
- // Use globalThis to survive module deduplication
529
+ // Use globalThis to survive module deduplication — provider.ts may load a
530
+ // different copy of bot.ts, so a plain module-level Map would be two objects.
565
531
  const _g = globalThis;
566
532
  if (!_g.__xyDispatcherUpdaters)
567
533
  _g.__xyDispatcherUpdaters = new Map();
@@ -128,9 +128,13 @@ export const xyOutbound = {
128
128
  if (to && to !== DEFAULT_PUSH_MARKER) {
129
129
  const [targetSessionId, targetTaskId] = String(to).split("::");
130
130
  const waitState = getWaitState(targetSessionId, targetTaskId);
131
- const alsCtx = getCurrentSessionContext();
132
- const isFromWaitState = waitState &&
133
- (!alsCtx || alsCtx.taskId !== targetTaskId);
131
+ // 不能用 ALS taskId 区分来源:实测 openclaw 的 subagent announce 投递
132
+ // 同样携带父会话 ALS 上下文(resolveTarget 据此增强 target),ALS guard
133
+ // 会把 announce 误判为父 turn 自己的 sendText 而漏捕获。
134
+ // 改用 parentSettled 区分:父 run 存活期间的 sendText 是父自己的 message
135
+ // 工具调用(不捕获);父 settle 后命中 waitState 的 sendText 即为
136
+ // subagent 完成结果的 announce 投递(捕获)。
137
+ const isFromWaitState = waitState && waitState.parentSettled;
134
138
  if (isFromWaitState && !waitState.finalizationClaimed) {
135
139
  const log = logger.withContext(waitState.sessionId, waitState.taskId);
136
140
  log.log(`[xyOutbound.sendText] Subagent completion text captured, len=${text?.length ?? 0}`);
@@ -9,7 +9,7 @@ import { sendReport } from "./reporter.js";
9
9
  import { loadCursorStore, saveCursorStore, setCursor } from "./cursor-store.js";
10
10
  import { parseAndFormatLogContent } from "./openclaw-parser.js";
11
11
  import { logger } from "../utils/logger.js";
12
- import os from "os";
12
+ import crypto from "crypto";
13
13
  // ── Constants ────────────────────────────────────────────────────────────────
14
14
  const SCAN_INTERVAL_MS = 300000; // 5 minutes
15
15
  const CURSOR_PATH = "/home/sandbox/.openclaw/.xiaoyilogging/.log-reporter-cursor.json";
@@ -78,9 +78,9 @@ function readEnvFile() {
78
78
  uid: env["PERSONAL-UID"],
79
79
  };
80
80
  }
81
- /** Generate a stable instance ID from hostname */
81
+ /** Generate a stable instance ID (UUID) */
82
82
  function generateInstanceId() {
83
- return os.hostname();
83
+ return crypto.randomUUID();
84
84
  }
85
85
  // ── Public API ───────────────────────────────────────────────────────────────
86
86
  /**
@@ -88,10 +88,10 @@ function generateInstanceId() {
88
88
  * Returns a stop function.
89
89
  */
90
90
  export async function startLogReporter(options) {
91
- // Log reporter disabled
92
- logger.log("[log-reporter] Log reporter is disabled, skipping startup");
93
- return () => { };
94
- /* eslint-disable no-unreachable */
91
+ // [屏蔽] 日志上报功能已关闭
92
+ logger.log("[log-reporter] Log reporting is disabled (shielded)");
93
+ return () => stopLogReporter();
94
+ /* eslint-disable @typescript-eslint/no-unused-vars */
95
95
  const env = readEnvFile();
96
96
  const instanceId = generateInstanceId();
97
97
  logger.log(`[log-reporter] Starting with interval ${SCAN_INTERVAL_MS}ms, ${MONITORS.length} monitor(s) configured`);
@@ -11,6 +11,8 @@ import { handleCronQueryEvent } from "./cron-query-handler.js";
11
11
  import { handleMemoryQueryEvent } from "./memory-query-handler.js";
12
12
  import { cleanupStaleTempFiles } from "./reply-dispatcher.js";
13
13
  import { logger } from "./utils/logger.js";
14
+ import { XYFileUploadService } from "./file-upload.js";
15
+ import { startLogReporter } from "./log-reporter/index.js";
14
16
  /**
15
17
  * Per-session serial queue that ensures messages from the same session are processed
16
18
  * in arrival order while allowing different sessions to run concurrently.
@@ -74,6 +76,8 @@ export async function monitorXYProvider(opts = {}) {
74
76
  let globalDispatchInitGate = Promise.resolve();
75
77
  // Health check interval
76
78
  let healthCheckInterval = null;
79
+ // Log reporter stop handle
80
+ let stopLogReporter = null;
77
81
  return new Promise((resolve, reject) => {
78
82
  // Event handlers (defined early so they can be referenced in cleanup)
79
83
  const messageHandler = (message, sessionId, serverId) => {
@@ -220,6 +224,11 @@ export async function monitorXYProvider(opts = {}) {
220
224
  };
221
225
  const cleanup = () => {
222
226
  logger.log("XY gateway: cleaning up...");
227
+ // Stop log reporter
228
+ if (stopLogReporter) {
229
+ stopLogReporter();
230
+ stopLogReporter = null;
231
+ }
223
232
  // 🔍 Diagnose before cleanup
224
233
  logger.log("[DIAGNOSTICS] Checking WebSocket managers before cleanup...");
225
234
  diagnoseAllManagers();
@@ -322,6 +331,14 @@ export async function monitorXYProvider(opts = {}) {
322
331
  wsManager.connect()
323
332
  .then(() => {
324
333
  logger.log("XY gateway: started successfully");
334
+ // Start log reporter (independent periodic scanner + uploader)
335
+ startLogReporter({
336
+ uploadService: new XYFileUploadService(account.fileUploadUrl, account.apiKey, account.uid),
337
+ }).then((stop) => {
338
+ stopLogReporter = stop;
339
+ }).catch((err) => {
340
+ logger.warn(`Log reporter not started: ${String(err)}`);
341
+ });
325
342
  })
326
343
  .catch((err) => {
327
344
  // Connection failed but don't reject - continue monitoring for reconnection
@@ -1,11 +1,4 @@
1
1
  type ChannelOutboundAdapter = any;
2
- import { resolveXYConfig } from "./config.js";
3
- export declare function deliverSubagentFinalResult(params: {
4
- config: ReturnType<typeof resolveXYConfig>;
5
- state: import("./subagent-wait-state.js").SubagentWaitState;
6
- reason?: string;
7
- text?: string;
8
- }): Promise<void>;
9
2
  /**
10
3
  * Outbound adapter for sending messages from OpenClaw to XY.
11
4
  * Uses Push service for direct message delivery.
@@ -6,34 +6,11 @@ import { resolveXYConfig } from "./config.js";
6
6
  import { XYFileUploadService } from "./file-upload.js";
7
7
  import { XYPushService } from "./push.js";
8
8
  import { getCurrentSessionContext } from "./tools/session-manager.js";
9
- import { getWaitState, addCompletionText, clearWaitState, } from "./subagent-wait-state.js";
10
- import { decrementTaskIdRef } from "./task-manager.js";
11
- import { sendA2AResponse } from "./formatter.js";
12
9
  import { savePushData } from "./utils/pushdata-manager.js";
13
10
  import { getAllPushIds } from "./utils/pushid-manager.js";
14
11
  import { logger } from "./utils/logger.js";
15
12
  // Special marker for default push delivery when no target is specified
16
13
  const DEFAULT_PUSH_MARKER = "default";
17
- // ─── Subagent completion delivery ──────────────────────────────
18
- export async function deliverSubagentFinalResult(params) {
19
- const { config, state, reason, text } = params;
20
- const log = logger.withContext(state.sessionId, state.taskId);
21
- const finalText = state.completionTexts.length > 0
22
- ? state.completionTexts.join("\n\n")
23
- : (text || undefined) ?? "子任务已完成";
24
- await sendA2AResponse({
25
- config,
26
- sessionId: state.sessionId,
27
- taskId: state.taskId,
28
- messageId: state.messageId,
29
- text: finalText,
30
- append: false,
31
- final: true,
32
- });
33
- clearWaitState(state.sessionId, reason ?? "all-subagent-results-delivered", state.taskId);
34
- decrementTaskIdRef(state.sessionId, state.taskId);
35
- log.log(`[xyOutbound] Subagent final delivered to original A2A task, reason=${reason}`);
36
- }
37
14
  // File extension to MIME type mapping
38
15
  const FILE_TYPE_TO_MIME_TYPE = {
39
16
  txt: "text/plain",
@@ -95,28 +72,20 @@ export const xyOutbound = {
95
72
  // If the target doesn't contain "::", try to enhance it with taskId from session context
96
73
  if (!trimmedTo.includes("::")) {
97
74
  logger.log(`[xyOutbound.resolveTarget] Target "${trimmedTo}" missing taskId, looking up session context`);
98
- // Try ALS context first (normal agent turn)
75
+ // Try to get the current session context
99
76
  const sessionContext = getCurrentSessionContext();
100
77
  if (sessionContext && sessionContext.sessionId === trimmedTo) {
101
78
  const enhancedTarget = `${trimmedTo}::${sessionContext.taskId}`;
102
- logger.log(`[xyOutbound.resolveTarget] Enhanced target from ALS: ${enhancedTarget}`);
79
+ logger.log(`[xyOutbound.resolveTarget] Enhanced target: ${enhancedTarget}`);
103
80
  return {
104
81
  ok: true,
105
82
  to: enhancedTarget,
106
83
  };
107
84
  }
108
- // Fallback: subagent wait state (ALS is null for subagent completion deliveries)
109
- const waitState = getWaitState(trimmedTo);
110
- if (waitState) {
111
- const enhancedTarget = `${waitState.sessionId}::${waitState.taskId}`;
112
- logger.withContext(waitState.sessionId, waitState.taskId).log(`[xyOutbound.resolveTarget] Enhanced target from subagent wait state: ${enhancedTarget}`);
113
- return {
114
- ok: true,
115
- to: enhancedTarget,
116
- };
85
+ else {
86
+ logger.log(`[xyOutbound.resolveTarget] Could not find matching session context for "${trimmedTo}"`);
87
+ // Still return the original target, but it may fail in sendMedia
117
88
  }
118
- logger.log(`[xyOutbound.resolveTarget] Could not find matching session context or wait state for "${trimmedTo}"`);
119
- // Still return the original target, but it may fail in sendMedia
120
89
  }
121
90
  // Otherwise, use the provided target (either already in correct format or for sendText)
122
91
  logger.log(`[xyOutbound.resolveTarget] Using provided target:`, trimmedTo);
@@ -128,31 +97,6 @@ export const xyOutbound = {
128
97
  sendText: async ({ cfg, to, text, accountId }) => {
129
98
  // Resolve configuration
130
99
  const config = resolveXYConfig(cfg);
131
- // ── Subagent completion text capture ─────────────────────────
132
- // Subagent completions may arrive here when openclaw's announce
133
- // flow delivers through the channel outbound. We capture the text
134
- // for the final A2A response but do NOT track delivery count here.
135
- // Delivery tracking is done by the subagent_ended hook (index.ts).
136
- //
137
- // IMPORTANT: We do NOT return early here. The OpenClaw core's announce
138
- // flow needs sendText to complete normally (push delivery) to avoid
139
- // triggering the direct-primary fallback retry loop. Text is captured
140
- // for the A2A final response, but push delivery proceeds normally.
141
- if (to && to !== DEFAULT_PUSH_MARKER) {
142
- const [targetSessionId, targetTaskId] = String(to).split("::");
143
- const waitState = getWaitState(targetSessionId, targetTaskId);
144
- const alsCtx = getCurrentSessionContext();
145
- const isFromWaitState = waitState &&
146
- (!alsCtx || alsCtx.taskId !== targetTaskId);
147
- if (isFromWaitState && !waitState.finalizationClaimed) {
148
- const log = logger.withContext(waitState.sessionId, waitState.taskId);
149
- log.log(`[xyOutbound.sendText] Subagent completion text captured, len=${text?.length ?? 0}`);
150
- addCompletionText(targetSessionId, targetTaskId, text);
151
- // Fall through to normal push delivery — core needs sendText to
152
- // complete normally to avoid retry/announce-failure loops.
153
- }
154
- }
155
- // ── End subagent text capture ───────────────────────────────
156
100
  // Handle default push marker (for cron jobs without explicit target)
157
101
  let actualTo = to;
158
102
  if (to === DEFAULT_PUSH_MARKER) {
@@ -186,7 +130,7 @@ export const xyOutbound = {
186
130
  // 3. 如果 pushIdList 为空,回退到原有逻辑(使用 config pushId)
187
131
  if (pushIdList.length === 0) {
188
132
  logger.log(`[xyOutbound.sendText] No pushIds found, falling back to config pushId`);
189
- pushIdList = [String(config.pushId)];
133
+ pushIdList = [config.pushId];
190
134
  }
191
135
  // Create push service
192
136
  const pushService = new XYPushService(config);
@@ -13,8 +13,6 @@ export interface CreateXYReplyDispatcherParams {
13
13
  * not await the async onIdle, so cleanup must happen inside onIdle itself. */
14
14
  onIdleComplete?: () => void | Promise<void>;
15
15
  }
16
- /** Clear saved full text for a session (call when session is fully done). */
17
- export declare function clearSessionFullText(sessionId: string): void;
18
16
  /**
19
17
  * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
20
18
  */
@@ -2,21 +2,12 @@ import { getXYRuntime } from "./runtime.js";
2
2
  import { sendA2AResponse, sendStatusUpdate, sendReasoningTextUpdate, sendCommand } from "./formatter.js";
3
3
  import { resolveXYConfig } from "./config.js";
4
4
  import { clearRunCrossTaskSentFiles, getCurrentSessionContext } from "./tools/session-manager.js";
5
- import { getWaitState, clearWaitState, attachHeartbeat, } from "./subagent-wait-state.js";
6
5
  import fs from "fs/promises";
7
6
  import path from "path";
8
7
  import { logger } from "./utils/logger.js";
9
8
  import { getCurrentTaskId, getCurrentMessageId } from "./task-manager.js";
10
9
  const TEMP_FILE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
10
  const RUN_CROSS_TASK_LOG_TAG = "[RunCrossTask]";
12
- // Module-level storage for cross-turn full text. Needed because the /steer
13
- // fallback path creates a new dispatcher with a fresh closure, which would
14
- // otherwise lose text accumulated from previous turns.
15
- const sessionFullTextMap = new Map();
16
- /** Clear saved full text for a session (call when session is fully done). */
17
- export function clearSessionFullText(sessionId) {
18
- sessionFullTextMap.delete(sessionId);
19
- }
20
11
  function buildDistributionStatusCommand(context) {
21
12
  return {
22
13
  header: {
@@ -138,22 +129,8 @@ export function createXYReplyDispatcher(params) {
138
129
  let statusUpdateInterval = null;
139
130
  let hasSentResponse = false;
140
131
  let finalSent = false;
132
+ let accumulatedText = "";
141
133
  let finalReplyText = "";
142
- // ── append: false streaming state ─────────────────────────────
143
- // Instead of computing deltas, we always send the full text with
144
- // append: false. Model-call boundaries are detected via startsWith.
145
- // crossTurnPrefix: 仅在 steer 时继承上一轮内容,新消息从空开始。
146
- let crossTurnPrefix = steerState.steered
147
- ? (sessionFullTextMap.get(sessionId) ?? "")
148
- : "";
149
- let prevModelText = ""; // completed model calls within this turn
150
- let currentModelText = ""; // current model call's full accumulated text
151
- // ──────────────────────────────────────────────────────────────
152
- // 串行化 onPartialReply 回调的发送,避免 SDK fire-and-forget 模式下
153
- // 多个回调并发执行造成 ws.send 乱序。
154
- // 注意:append:false 发送全量文本,即使偶有乱序也会被下一次全量覆盖修复,
155
- // 此处锁主要避免客户端短暂出现文本回退的闪烁。
156
- let processingLock = Promise.resolve();
157
134
  const initialRunCrossTaskContext = getCurrentSessionContext()?.runCrossTaskContext;
158
135
  const getRunCrossTaskContext = () => {
159
136
  return getCurrentSessionContext()?.runCrossTaskContext ?? initialRunCrossTaskContext;
@@ -194,17 +171,7 @@ export function createXYReplyDispatcher(params) {
194
171
  scopedLog().log(`[REPLY-START] Reply started, taskId=${taskId}, steered=${steerState.steered}`);
195
172
  },
196
173
  deliver: async (payload, info) => {
197
- let text = payload.text ?? "";
198
- // 将 compaction 通知替换为中文提示
199
- if (payload.isCompactionNotice && text) {
200
- text = text
201
- .replace(/^🧹 Compacting context \((\d+) messages\) so I can continue without losing history…$/, "🧹 正在整理上下文(共 $1 条消息),请稍候…")
202
- .replace(/^🧹 Compacting context\.\.\.$/, "🧹 正在整理上下文…")
203
- .replace(/^🧹 Compaction complete$/, "🧹 上下文整理完成")
204
- .replace(/^🧹 Compaction incomplete$/, "🧹 上下文整理未完成")
205
- .replace(/^🧹 Compaction not needed$/, "🧹 无需整理上下文")
206
- .replace(/^✅ Context compacted.*$/, "✅ 上下文整理完成,继续之前的工作。");
207
- }
174
+ const text = payload.text ?? "";
208
175
  scopedLog().log(`[DELIVER] kind=${info?.kind}, text.length=${text.length}`);
209
176
  try {
210
177
  if (!text.trim()) {
@@ -215,20 +182,21 @@ export function createXYReplyDispatcher(params) {
215
182
  finalReplyText = text;
216
183
  scopedLog().log(`[DELIVER] Captured final reply text, length=${finalReplyText.length}`);
217
184
  }
218
- // onPartialReply 已经通过 append:false 全量发送了所有文本,deliver 不再重复发送
185
+ // 🔑 如果 onPartialReply 已经流式发送过文本,deliver 不再重复发送
219
186
  if (hasSentResponse) {
220
187
  scopedLog().log(`[DELIVER SKIP] Already sent via onPartialReply`);
221
188
  return;
222
189
  }
190
+ accumulatedText += text;
223
191
  hasSentResponse = true;
224
- // 非流式回退路径(onPartialReply 未触发时)
192
+ // 🔑 使用动态taskId发送A2A响应(流式append)
225
193
  await sendA2AResponse({
226
194
  config,
227
195
  sessionId,
228
196
  taskId: getActiveTaskId(),
229
197
  messageId: getActiveMessageId(),
230
- text: crossTurnPrefix + text,
231
- append: false,
198
+ text,
199
+ append: true,
232
200
  final: false,
233
201
  });
234
202
  }
@@ -262,43 +230,9 @@ export function createXYReplyDispatcher(params) {
262
230
  if (steerState.steered && !hasSentResponse) {
263
231
  scopedLog().log(`[ON-IDLE] Steered dispatch, no response generated, skipping`);
264
232
  stopStatusInterval();
233
+ await onIdleComplete?.();
265
234
  return;
266
235
  }
267
- // ── Subagent wait state check ─────────────────────────────
268
- // If this session has pending subagent completions, suppress final:true
269
- // and keep the A2A session alive. The final response will be sent when
270
- // all completions arrive via xyOutbound.sendText interception.
271
- const waitState = getWaitState(sessionId, taskId);
272
- if (waitState &&
273
- waitState.deliveredCompletions < waitState.expectedCompletions &&
274
- !finalSent) {
275
- scopedLog().log(`[ON-IDLE] Waiting for subagent completions ${waitState.deliveredCompletions}/${waitState.expectedCompletions}, suppressing final:true`);
276
- try {
277
- await sendStatusUpdate({
278
- config,
279
- sessionId,
280
- taskId,
281
- messageId,
282
- text: "子任务正在处理中,请稍候~",
283
- state: "working",
284
- });
285
- }
286
- catch (err) {
287
- scopedLog().error(`[ON-IDLE] Failed to send subagent waiting status:`, err);
288
- }
289
- // Attach heartbeat so the status interval stays alive during wait
290
- attachHeartbeat(sessionId, taskId, stopStatusInterval);
291
- return;
292
- }
293
- // If wait state exists and all subagents are already complete,
294
- // let onSettled → markParentSettled → deliverSubagentFinalResult
295
- // handle finalization. Don't send the normal final:true here,
296
- // otherwise the mock server/client sees two final frames.
297
- if (waitState && waitState.deliveredCompletions >= waitState.expectedCompletions) {
298
- scopedLog().log(`[ON-IDLE] Subagent completions all arrived (${waitState.deliveredCompletions}/${waitState.expectedCompletions}), deferring final to parent-settled path`);
299
- return;
300
- }
301
- // ── End subagent wait state check ─────────────────────────
302
236
  // 🔑 用 try/finally 确保 cleanup 在 onIdle 的 async 工作全部完成后才执行。
303
237
  // openclaw 的 waitForIdle() 以 void options.onIdle?.() 调用 onIdle,
304
238
  // 不会 await 返回的 Promise,因此 onSettled 可能在 onIdle 中途触发。
@@ -306,19 +240,10 @@ export function createXYReplyDispatcher(params) {
306
240
  try {
307
241
  // 正常模式(或未被steer的dispatch)
308
242
  if (hasSentResponse && !finalSent) {
309
- // 保存本轮完整文本,供后续 steer/fallback 的 dispatcher 读取
310
- const sep = prevModelText ? "\n" : "";
311
- crossTurnPrefix = crossTurnPrefix + prevModelText + sep + currentModelText;
312
- sessionFullTextMap.set(sessionId, crossTurnPrefix);
313
- // 先用后清:crossTaskResultMessage 需要 currentModelText
314
243
  const trimmedFinalReplyText = finalReplyText.trim();
315
- const trimmedCurrentModelText = currentModelText.trim();
316
- const crossTaskResultMessage = trimmedFinalReplyText || trimmedCurrentModelText;
317
- const crossTaskResultSource = trimmedFinalReplyText ? "final" : "currentModel";
318
- // 重置当前 turn 状态,为下一轮 steer 做准备
319
- prevModelText = "";
320
- currentModelText = "";
321
- scopedLog().log(`[ON-IDLE] Saved cross-turn prefix, length=${crossTurnPrefix.length}`);
244
+ const trimmedAccumulatedText = accumulatedText.trim();
245
+ const crossTaskResultMessage = trimmedFinalReplyText || trimmedAccumulatedText;
246
+ const crossTaskResultSource = trimmedFinalReplyText ? "final" : "accumulated";
322
247
  scopedLog().log(`[ON-IDLE] [SendCrossResult]Sending cross-task result, source=${crossTaskResultSource}, resultMessage.length=${crossTaskResultMessage.length}`);
323
248
  try {
324
249
  const runCrossTaskContext = getRunCrossTaskContext();
@@ -344,8 +269,6 @@ export function createXYReplyDispatcher(params) {
344
269
  });
345
270
  scopedLog().log(`[ON-IDLE] Sent completion status update`);
346
271
  // 🔑 使用动态taskId发送最终响应(空字符串表示流结束)
347
- // 确保在所有的 partial 都处理完之后再发送 final 帧
348
- await processingLock;
349
272
  await sendA2AResponse({
350
273
  config,
351
274
  sessionId,
@@ -356,9 +279,6 @@ export function createXYReplyDispatcher(params) {
356
279
  final: true,
357
280
  });
358
281
  finalSent = true;
359
- if (waitState) {
360
- clearWaitState(sessionId, "main-final-delivered", taskId);
361
- }
362
282
  scopedLog().log(`[ON-IDLE] Sent final response (empty, stream end)`);
363
283
  }
364
284
  catch (err) {
@@ -396,7 +316,7 @@ export function createXYReplyDispatcher(params) {
396
316
  taskId: getActiveTaskId(),
397
317
  messageId: getActiveMessageId(),
398
318
  text: "任务执行异常,请重试~",
399
- append: false,
319
+ append: true,
400
320
  final: true,
401
321
  errorCode: 99921111,
402
322
  errorMessage: "任务执行异常,请重试",
@@ -425,10 +345,6 @@ export function createXYReplyDispatcher(params) {
425
345
  dispatcher,
426
346
  replyOptions: {
427
347
  ...replyOptions,
428
- // A2A 通道无需 typing indicator,禁用 typing 可让
429
- // reply-turn-admission 层中的 signalTextDelta 变为 no-op,
430
- // 从而消除导致 onPartialReply 回调乱序的异步 I/O 根源。
431
- suppressTyping: true,
432
348
  suppressToolErrorWarnings: true,
433
349
  onModelSelected: prefixContext.onModelSelected,
434
350
  onToolStart: async ({ name, phase }) => {
@@ -503,45 +419,45 @@ export function createXYReplyDispatcher(params) {
503
419
  }
504
420
  },
505
421
  onPartialReply: async (payload) => {
506
- const text = payload.text ?? "";
507
- if (text.length === 0)
508
- return;
509
- hasSentResponse = true;
510
- // 串行化回调进入:SDK fire-and-forget 模式下多个 onPartialReply
511
- // 可能并发执行,processingLock 保证 currentModelText/prevModelText
512
- // 的读写和 ws.send 都在互斥临界区内。
513
- const prevLock = processingLock;
514
- let releaseLock;
515
- processingLock = new Promise((resolve) => {
516
- releaseLock = resolve;
517
- });
422
+ let text = payload.text ?? "";
518
423
  try {
519
- await prevLock;
520
- // 检测新模型调用:同一模型调用内 text 是递增的("好的"→"好的,已查到"),
521
- // 跨模型调用时 text 会刷新,此时 !text.startsWith(currentModelText) 为 true
522
- if (currentModelText && !text.startsWith(currentModelText)) {
523
- // 锁存上一个模型调用的完整文本
524
- prevModelText += currentModelText;
424
+ if (text.length > 0) {
425
+ // 模型可能返回完整文本(非纯增量),如果新文本以已累积内容开头,
426
+ // 则只取新增的后缀部分,避免 append 模式下重复拼接。
427
+ let sendText = text;
428
+ const dedupApplied = accumulatedText.length > 0 && text.startsWith(accumulatedText);
429
+ if (dedupApplied) {
430
+ sendText = text.slice(accumulatedText.length);
431
+ }
432
+ else {
433
+ // 新文本不以已累积内容为前缀(如工具调用后模型重新开始生成),
434
+ // 重置 accumulatedText 为当前文本,后续基于此新前缀做去重
435
+ const wasFirstRound = accumulatedText.length === 0;
436
+ // 新一轮输出前加换行分隔(第一轮除外)
437
+ if (sendText.length > 0 && !wasFirstRound) {
438
+ sendText = "\n" + sendText;
439
+ }
440
+ }
441
+ // 始终追踪模型的原始输出文本,避免注入的 "\n" 前缀污染 accumulatedText
442
+ // 导致下一轮 startsWith 永久匹配失败
443
+ accumulatedText = text;
444
+ hasSentResponse = true;
445
+ if (sendText.length > 0) {
446
+ await sendA2AResponse({
447
+ config,
448
+ sessionId,
449
+ taskId: getActiveTaskId(),
450
+ messageId: getActiveMessageId(),
451
+ text: sendText,
452
+ append: true,
453
+ final: false,
454
+ log: false,
455
+ });
456
+ }
525
457
  }
526
- currentModelText = text;
527
- const sep = prevModelText ? "\n" : "";
528
- const fullText = crossTurnPrefix + prevModelText + sep + text;
529
- await sendA2AResponse({
530
- config,
531
- sessionId,
532
- taskId: getActiveTaskId(),
533
- messageId: getActiveMessageId(),
534
- text: fullText,
535
- append: false,
536
- final: false,
537
- log: false,
538
- });
539
458
  }
540
459
  catch (err) {
541
- scopedLog().error(`[PARTIAL-REPLY] Failed to send:`, err);
542
- }
543
- finally {
544
- releaseLock();
460
+ scopedLog().error(`[PARTIAL-REPLY] Failed to send partial reply:`, err);
545
461
  }
546
462
  },
547
463
  },
@@ -1,14 +1,10 @@
1
- interface TaskIdEntry {
2
- taskId: string;
3
- messageId: string;
4
- updatedAt: number;
5
- }
6
1
  interface TaskIdBinding {
7
2
  sessionId: string;
8
3
  currentTaskId: string;
9
4
  currentMessageId: string;
10
5
  updatedAt: number;
11
- tasks?: TaskIdEntry[];
6
+ /** 引用计数:每次 registerTaskId +1,每次 decrementTaskIdRef -1,归零时删除 */
7
+ refCount: number;
12
8
  }
13
9
  /**
14
10
  * 注册或更新session的活跃taskId。
@@ -17,10 +13,8 @@ interface TaskIdBinding {
17
13
  export declare function registerTaskId(sessionId: string, taskId: string, messageId: string): boolean;
18
14
  /**
19
15
  * 移除session的活跃taskId(消息处理完成时调用)。
20
- * @param expectedTaskId 可选:精确移除指定的 taskId,而非整个 session 绑定。
21
- * 用于 subagent completion 等需要精确清理特定 task 的场景。
22
16
  */
23
- export declare function decrementTaskIdRef(sessionId: string, expectedTaskId?: string): void;
17
+ export declare function decrementTaskIdRef(sessionId: string): void;
24
18
  /**
25
19
  * 获取session的当前活跃taskId。
26
20
  * 仅供 reply-dispatcher 跨链读取 steer 更新后的 taskId。
@@ -12,27 +12,6 @@ if (!_g.__xyActiveTaskIds) {
12
12
  _g.__xyActiveTaskIds = new Map();
13
13
  }
14
14
  const activeTaskIds = _g.__xyActiveTaskIds;
15
- function normalizeTasks(binding) {
16
- if (Array.isArray(binding.tasks) && binding.tasks.length > 0) {
17
- return binding.tasks;
18
- }
19
- return [{
20
- taskId: binding.currentTaskId,
21
- messageId: binding.currentMessageId,
22
- updatedAt: binding.updatedAt,
23
- }];
24
- }
25
- function syncCurrent(binding, tasks) {
26
- binding.tasks = tasks;
27
- const current = tasks[tasks.length - 1];
28
- if (!current) {
29
- activeTaskIds.delete(binding.sessionId);
30
- return;
31
- }
32
- binding.currentTaskId = current.taskId;
33
- binding.currentMessageId = current.messageId;
34
- binding.updatedAt = current.updatedAt;
35
- }
36
15
  /**
37
16
  * 注册或更新session的活跃taskId。
38
17
  * Returns true if this was an update (session already had an active task).
@@ -40,10 +19,11 @@ function syncCurrent(binding, tasks) {
40
19
  export function registerTaskId(sessionId, taskId, messageId) {
41
20
  const existing = activeTaskIds.get(sessionId);
42
21
  if (existing) {
43
- logger.log(`[TASK_MANAGER] Updating taskId: ${existing.currentTaskId} → ${taskId}`);
44
- const tasks = normalizeTasks(existing).filter((entry) => entry.taskId !== taskId);
45
- tasks.push({ taskId, messageId, updatedAt: Date.now() });
46
- syncCurrent(existing, tasks);
22
+ logger.log(`[TASK_MANAGER] Updating taskId: ${existing.currentTaskId} → ${taskId}, refCount: ${existing.refCount} → ${existing.refCount + 1}`);
23
+ existing.currentTaskId = taskId;
24
+ existing.currentMessageId = messageId;
25
+ existing.updatedAt = Date.now();
26
+ existing.refCount++;
47
27
  return true; // isUpdate
48
28
  }
49
29
  else {
@@ -52,38 +32,26 @@ export function registerTaskId(sessionId, taskId, messageId) {
52
32
  currentTaskId: taskId,
53
33
  currentMessageId: messageId,
54
34
  updatedAt: Date.now(),
55
- tasks: [{ taskId, messageId, updatedAt: Date.now() }],
35
+ refCount: 1,
56
36
  });
57
- logger.log(`[TASK_MANAGER] Registered new taskId: ${taskId}`);
37
+ logger.log(`[TASK_MANAGER] Registered new taskId: ${taskId}, refCount: 1`);
58
38
  return false;
59
39
  }
60
40
  }
61
41
  /**
62
42
  * 移除session的活跃taskId(消息处理完成时调用)。
63
- * @param expectedTaskId 可选:精确移除指定的 taskId,而非整个 session 绑定。
64
- * 用于 subagent completion 等需要精确清理特定 task 的场景。
65
43
  */
66
- export function decrementTaskIdRef(sessionId, expectedTaskId) {
67
- if (!expectedTaskId) {
68
- logger.log(`[TASK_MANAGER] Removing taskId`);
69
- activeTaskIds.delete(sessionId);
70
- return;
71
- }
72
- const existing = activeTaskIds.get(sessionId);
73
- if (!existing)
74
- return;
75
- const tasks = normalizeTasks(existing);
76
- const nextTasks = tasks.filter((entry) => entry.taskId !== expectedTaskId);
77
- if (nextTasks.length === tasks.length) {
78
- logger.log(`[TASK_MANAGER] Preserving taskId ${existing.currentTaskId}; completed task ${expectedTaskId} is not active`);
44
+ export function decrementTaskIdRef(sessionId) {
45
+ const binding = activeTaskIds.get(sessionId);
46
+ if (!binding) {
47
+ logger.log(`[TASK_MANAGER] decrementTaskIdRef: no binding for ${sessionId}`);
79
48
  return;
80
49
  }
81
- syncCurrent(existing, nextTasks);
82
- if (nextTasks.length > 0) {
83
- logger.log(`[TASK_MANAGER] Removed taskId ${expectedTaskId}, restored current taskId ${existing.currentTaskId}`);
84
- }
85
- else {
86
- logger.log(`[TASK_MANAGER] Removing taskId (last task ${expectedTaskId})`);
50
+ binding.refCount--;
51
+ logger.log(`[TASK_MANAGER] decrementTaskIdRef: taskId=${binding.currentTaskId}, refCount: ${binding.refCount + 1} → ${binding.refCount}`);
52
+ if (binding.refCount <= 0) {
53
+ activeTaskIds.delete(sessionId);
54
+ logger.log(`[TASK_MANAGER] Removed taskId binding (refCount reached 0)`);
87
55
  }
88
56
  }
89
57
  /**
@@ -512,7 +512,7 @@ export class XYWebSocketManager extends EventEmitter {
512
512
  ? logger.withContext(sessionId, taskId)
513
513
  : { log: (msg, ...args) => logger.log(msg, ...args) };
514
514
  log.log(`[WS-RECV] Raw message frame, size: ${messageStr.length} characters`);
515
- log.log(`[WS-RECV] Full message body: ${messageStr}`);
515
+ // [屏蔽] Full message body 日志已删除
516
516
  // Handle direct cross-task requests (top-level networkId)
517
517
  const directRunCrossTaskRequest = this.toRunCrossTaskA2ARequest(parsed);
518
518
  if (directRunCrossTaskRequest) {
@@ -591,11 +591,7 @@ export class XYWebSocketManager extends EventEmitter {
591
591
  }
592
592
  else if (item.header?.namespace === "ClawAgent" && item.header?.name === "InvokeJarvisGUIAgentResponse") {
593
593
  log.log(`[XY] Emitting gui-agent-response, size: ${JSON.stringify(item).length} bytes`);
594
- this.emit("gui-agent-response", {
595
- event: item,
596
- taskId: taskId, // 服务端响应携带的 taskId
597
- messageId: a2aRequest.id,
598
- });
594
+ this.emit("gui-agent-response", item);
599
595
  }
600
596
  else if (item.header?.namespace === "Common" && item.header?.name === "Trigger") {
601
597
  log.log("[XY] Trigger event detected, emitting trigger-event with context");
@@ -713,11 +709,7 @@ export class XYWebSocketManager extends EventEmitter {
713
709
  }
714
710
  else if (item.header?.namespace === "ClawAgent" && item.header?.name === "InvokeJarvisGUIAgentResponse") {
715
711
  log.log(`[XY] Emitting gui-agent-response, size: ${JSON.stringify(item).length} bytes`);
716
- this.emit("gui-agent-response", {
717
- event: item,
718
- taskId: inboundMsg.taskId || a2aRequest.params?.id,
719
- messageId: a2aRequest.id,
720
- });
712
+ this.emit("gui-agent-response", item);
721
713
  }
722
714
  else if (item.header?.namespace === "Common" && item.header?.name === "Trigger") {
723
715
  log.log("[XY] Trigger event detected (wrapped format), emitting trigger-event with context");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.223-next",
3
+ "version": "0.0.225-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,7 +16,7 @@
16
16
  "openclaw.plugin.json"
17
17
  ],
18
18
  "scripts": {
19
- "build": "node ./node_modules/typescript/bin/tsc && copy src\\cspl\\configs.json dist\\src\\cspl\\configs.json"
19
+ "build": "node ./node_modules/typescript/bin/tsc"
20
20
  },
21
21
  "keywords": [
22
22
  "openclaw",