@ynhcj/xiaoyi-channel 0.0.226-next → 0.0.228-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.
Files changed (64) hide show
  1. package/dist/index.js +28 -161
  2. package/dist/src/bot.js +28 -36
  3. package/dist/src/channel.js +2 -8
  4. package/dist/src/config-schema.d.ts +5 -0
  5. package/dist/src/config-schema.js +5 -0
  6. package/dist/src/config.js +1 -0
  7. package/dist/src/conversation/conversation-manager.js +4 -0
  8. package/dist/src/conversation/conversation-session.d.ts +6 -0
  9. package/dist/src/cron-command.js +24 -2
  10. package/dist/src/cspl/sentinel_hook.js +11 -8
  11. package/dist/src/cspl/utils.js +8 -8
  12. package/dist/src/dispatch/outbound.js +16 -7
  13. package/dist/src/formatter.d.ts +0 -7
  14. package/dist/src/formatter.js +94 -71
  15. package/dist/src/monitor.js +0 -17
  16. package/dist/src/outbound.js +5 -7
  17. package/dist/src/provider.js +214 -52
  18. package/dist/src/reply-dispatcher.js +163 -63
  19. package/dist/src/self-evolution-handler.d.ts +1 -1
  20. package/dist/src/task-manager.d.ts +9 -3
  21. package/dist/src/task-manager.js +48 -16
  22. package/dist/src/tools/agent-as-skill-tool.js +1 -1
  23. package/dist/src/tools/calendar-tool.js +1 -1
  24. package/dist/src/tools/call-device-tool.js +13 -24
  25. package/dist/src/tools/call-phone-tool.js +1 -1
  26. package/dist/src/tools/check-plugin-privilege-tool.js +1 -1
  27. package/dist/src/tools/create-alarm-tool.js +1 -1
  28. package/dist/src/tools/delete-alarm-tool.js +1 -1
  29. package/dist/src/tools/discover-cross-devices-tool.js +1 -1
  30. package/dist/src/tools/hmos-cli.js +2 -2
  31. package/dist/src/tools/invoke.js +7 -3
  32. package/dist/src/tools/location-tool.js +1 -1
  33. package/dist/src/tools/login-token-tool.js +1 -1
  34. package/dist/src/tools/modify-alarm-tool.js +1 -1
  35. package/dist/src/tools/modify-note-tool.js +1 -1
  36. package/dist/src/tools/note-tool.js +1 -1
  37. package/dist/src/tools/query-app-message-tool.js +1 -1
  38. package/dist/src/tools/query-memory-data-tool.js +1 -1
  39. package/dist/src/tools/query-todo-task-tool.js +1 -1
  40. package/dist/src/tools/save-file-to-phone-tool.js +1 -1
  41. package/dist/src/tools/save-media-to-gallery-tool.js +1 -1
  42. package/dist/src/tools/search-alarm-tool.js +1 -1
  43. package/dist/src/tools/search-calendar-tool.js +1 -1
  44. package/dist/src/tools/search-contact-tool.js +1 -1
  45. package/dist/src/tools/search-email-tool.js +1 -1
  46. package/dist/src/tools/search-file-tool.js +1 -1
  47. package/dist/src/tools/search-message-tool.js +1 -1
  48. package/dist/src/tools/search-note-tool.js +1 -1
  49. package/dist/src/tools/search-photo-gallery-tool.js +1 -1
  50. package/dist/src/tools/send-cross-device-task-tool.js +1 -1
  51. package/dist/src/tools/send-email-tool.js +1 -1
  52. package/dist/src/tools/send-file-to-user-tool.js +1 -1
  53. package/dist/src/tools/send-message-tool.js +1 -1
  54. package/dist/src/tools/session-manager.js +1 -1
  55. package/dist/src/tools/upload-file-tool.js +1 -1
  56. package/dist/src/tools/upload-photo-tool.js +1 -1
  57. package/dist/src/tools/xiaoyi-add-collection-tool.js +1 -1
  58. package/dist/src/tools/xiaoyi-collection-tool.js +1 -1
  59. package/dist/src/tools/xiaoyi-delete-collection-tool.js +1 -1
  60. package/dist/src/tools/xiaoyi-gui-tool.js +3 -3
  61. package/dist/src/trigger-handler.js +1 -1
  62. package/dist/src/types.d.ts +1 -0
  63. package/dist/src/websocket.js +10 -3
  64. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15,8 +15,6 @@ import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
15
15
  import { registerCLIHook } from "./src/tools/hmos-cli.js";
16
16
  import { recoverCronState } from "./src/cron-recovery.js";
17
17
  import { writeSkillUsage } from "./src/utils/skills-logger.js";
18
- import { markSubagentSpawned, markSubagentEnded, deliverSubagentFinalResult, } from "./src/conversation/conversation-manager.js";
19
- import { notifyCronAgentEnd } from "./src/conversation/cron-buffer.js";
20
18
  import { logger } from "./src/utils/logger.js";
21
19
  /**
22
20
  * Parse a file path string to detect if it refers to a SKILL.md file within
@@ -45,23 +43,37 @@ function extractSkillNameFromPath(filePath) {
45
43
  * the `after_tool_call` hook and write the skill name to the skills log.
46
44
  */
47
45
  function registerSkillsDiagnosticHook(api) {
48
- // Tool name → skill name mapping for direct tool-based skill usage logging
49
- const TOOL_SKILL_MAP = {
50
- get_user_location: "GetCurrentLocation",
51
- get_calendar_tool_schema: "Schedule",
52
- get_note_tool_schema: "memorandum",
53
- get_photo_tool_schema: "gallery",
54
- get_contact_tool_schema: "contact",
55
- get_device_file_tool_schema: "file",
56
- get_alarm_tool_schema: "clock",
57
- message: "message",
58
- get_phone_tool_schema: "phone",
59
- get_collection_tool_schema: "xiaoyi-collection",
60
- image_reading: "xiaoyi-image-understanding"
46
+ // Skill name → tool names mapping for direct tool-based skill usage logging
47
+ const SKILL_TOOLS = {
48
+ GetCurrentLocation: ["get_user_location"],
49
+ Schedule: ["create_calendar_event", "search_calendar_event"],
50
+ memorandum: ["create_note", "search_notes", "modify_note"],
51
+ gallery: ["search_photo_gallery", "save_media_to_gallery"],
52
+ contact: ["search_contact"],
53
+ file: ["search_file", "upload_file", "save_file_to_file_manager"],
54
+ clock: ["create_alarm", "delete_alarm", "search_alarm", "modify_alarm"],
55
+ message: ["search_message", "send_message"],
56
+ phone: ["call_phone"],
57
+ "xiaoyi-collection": ["add_collection", "query_collection", "delete_collection"],
58
+ "xiaoyi-image-understanding": ["image_reading"],
61
59
  };
60
+ // Inverted to tool name → skill name for lookup
61
+ const TOOL_SKILL_MAP = Object.fromEntries(Object.entries(SKILL_TOOLS).flatMap(([skill, tools]) => tools.map((t) => [t, skill])));
62
+ // Resolve the actual tool name from call_device_tool wrapper.
63
+ // The model calls call_device_tool({ toolName: "...", arguments: {...} })
64
+ // — the real tool name is inside params, not event.toolName.
65
+ function resolveActualToolName(event) {
66
+ if (event.toolName === "call_device_tool") {
67
+ const inner = event.params?.toolName;
68
+ if (typeof inner === "string" && inner.length > 0)
69
+ return inner;
70
+ }
71
+ return event.toolName;
72
+ }
62
73
  // Log skill usage for known device tools on before_tool_call
63
74
  api.on("before_tool_call", async (event, _ctx) => {
64
- const skillName = TOOL_SKILL_MAP[event.toolName];
75
+ const actualToolName = resolveActualToolName(event);
76
+ const skillName = TOOL_SKILL_MAP[actualToolName];
65
77
  if (skillName) {
66
78
  writeSkillUsage(skillName);
67
79
  }
@@ -306,152 +318,7 @@ function registerCronRecoveryHook(api) {
306
318
  logger.log(`${logTag} ═══════════════════════════════════════════`);
307
319
  });
308
320
  }
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
- }
356
- function registerSubagentHooks(api) {
357
- // subagent_spawned: fires after a subagent run is successfully registered.
358
- // We increment the expected completion count so that onIdle knows to wait.
359
- api.on("subagent_spawned", async (_event, ctx) => {
360
- const requesterSessionKey = ctx?.requesterSessionKey;
361
- if (!requesterSessionKey)
362
- return;
363
- const count = markSubagentSpawned(requesterSessionKey);
364
- if (count > 0) {
365
- logger.log(`[XY-SUBAGENT] spawned, requesterSessionKey=${requesterSessionKey.slice(0, 30)}, expected=${count}`);
366
- }
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
- });
394
- // subagent_ended: fires when a subagent run terminates (complete/error/killed).
395
- // This is the PRIMARY delivery tracking mechanism. When all expected
396
- // subagents have ended and parent has settled, we finalize the A2A session.
397
- api.on("subagent_ended", async (event, ctx) => {
398
- try {
399
- const requesterSessionKey = ctx?.requesterSessionKey;
400
- if (!requesterSessionKey) {
401
- logger.log(`[XY-SUBAGENT-END] no requesterSessionKey in ctx`);
402
- return;
403
- }
404
- const transition = markSubagentEnded(requesterSessionKey);
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}`);
406
- if (transition?.shouldFinalize) {
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));
412
- // 最终交付由对话管理层统一负责(含 config 解析、final 帧、清理)
413
- await deliverSubagentFinalResult({
414
- state: transition.state,
415
- reason: "all-subagents-ended-after-parent-settled",
416
- });
417
- logger.log(`[XY-SUBAGENT-END] Finalized A2A session after all subagents ended`);
418
- }
419
- }
420
- catch (err) {
421
- logger.error(`[XY-SUBAGENT-END] Error in subagent_ended hook:`, err);
422
- }
423
- });
424
- }
425
321
  function registerFullHooks(api) {
426
- // SUBAGENT HOOKS: track subagent spawn/end lifecycle for session keep-alive
427
- registerSubagentHooks(api);
428
- // CRON AGGREGATION: 通过 agent 事件总线感知 cron isolated run 结束。
429
- // 注意:agent_end 插件 hook 只在 dispatch-from-config 路径触发,cron
430
- // isolated run 不会触发它;embedded run 的 lifecycle 事件才是可靠信号。
431
- // phase=end/error 后到达的 sendText 即为 announce 最终结果(放行)。
432
- api.agent.events.registerAgentEventSubscription({
433
- id: "xy-cron-turn-tracker",
434
- description: "Track cron isolated run completion for sendText aggregation",
435
- streams: ["lifecycle"],
436
- handle: (event) => {
437
- try {
438
- const phase = event?.data?.phase;
439
- // 仅 phase=end 是 run 级终态信号;phase=error 可能是单次模型调用
440
- // 失败(后续还有重试/start),不能据此放行 announce。
441
- // 真正失败的 run(无 end)由 120s 安全超时兜底 flush 缓冲文本。
442
- if (phase !== "end")
443
- return;
444
- const sessionKey = event?.sessionKey ?? "";
445
- if (sessionKey.startsWith("cron:") || sessionKey.includes(":cron:") || isCronActive()) {
446
- logger.log(`[XY-CRON] Cron run lifecycle end, sessionKey=${sessionKey || "unknown"}`);
447
- notifyCronAgentEnd();
448
- }
449
- }
450
- catch (err) {
451
- logger.error(`[XY-CRON] Error in cron lifecycle subscription:`, err);
452
- }
453
- },
454
- });
455
322
  // SKILL RETRIEVER HOOK: before_prompt_build hook
456
323
  const pluginConfig = api.pluginConfig || {};
457
324
  const skillRetrieverConfig = normalizeToolRetrieverConfig({
package/dist/src/bot.js CHANGED
@@ -242,20 +242,18 @@ export async function handleXYMessage(params) {
242
242
  log.error(`[BOT] Failed to patch session model override:`, patchErr);
243
243
  }
244
244
  }
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
- }
245
+ // 🔑 发送初始状态更新
246
+ log.log(`[BOT] Sending initial status update`);
247
+ void sendStatusUpdate({
248
+ config,
249
+ sessionId: parsed.sessionId,
250
+ taskId: parsed.taskId,
251
+ messageId: parsed.messageId,
252
+ text: "任务正在处理中,请稍候~",
253
+ state: "working",
254
+ }).catch((err) => {
255
+ log.error(`Failed to send initial status update:`, err);
256
+ });
259
257
  }
260
258
  // Extract text and files from parts
261
259
  const text = extractTextFromParts(parsed.parts);
@@ -281,10 +279,8 @@ export async function handleXYMessage(params) {
281
279
  }
282
280
  }
283
281
  // ── Build message and dispatch via auto-reply pipeline ──────────
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
282
+ // 首条消息和 steer 回退都走此统一 dispatch 路径。
283
+ // File download — for both first message and steer fallthrough
288
284
  let mediaPayload = {};
289
285
  if (!skipReg || isUpdate) {
290
286
  const fileParts = extractFileParts(parsed.parts);
@@ -305,7 +301,7 @@ export async function handleXYMessage(params) {
305
301
  // per-session ReplyOperation lock in admitReplyTurn which would otherwise
306
302
  // block the steer message until the first message completes — making steer
307
303
  // indistinguishable from a followup.
308
- if (isUpdate && !skipReg && route.sessionKey) {
304
+ if (isUpdate && route.sessionKey) {
309
305
  const activeSessionId = resolveActiveEmbeddedRunSessionId(route.sessionKey);
310
306
  if (activeSessionId) {
311
307
  log.log(`[BOT-STEER] Direct steer attempt: activeSessionId=${activeSessionId}, textLen=${textForAgent.length}`);
@@ -314,8 +310,10 @@ export async function handleXYMessage(params) {
314
310
  });
315
311
  if (queued) {
316
312
  log.log(`[BOT-STEER] Direct steer succeeded — message injected into active run`);
317
- // Steer message taskId refCount is no longer needed since we skip the dispatcher.
318
- decrementTaskIdRef(parsed.sessionId);
313
+ // Steer message taskId stays in the binding so getActiveTaskId()
314
+ // resolves to the latest taskId for onPartialReply replies.
315
+ // Cleanup happens via the original dispatcher's onIdleComplete →
316
+ // decrementTaskIdRef(sessionId) which removes the entire binding.
319
317
  return;
320
318
  }
321
319
  log.log(`[BOT-STEER] Direct steer failed (queued=false), falling through to dispatchReplyFromConfig`);
@@ -327,10 +325,8 @@ export async function handleXYMessage(params) {
327
325
  // Resolve envelope format options (following feishu pattern)
328
326
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
329
327
  // Build message body with speaker prefix (following feishu pattern)
330
- let messageBody = textForAgent;
331
- // Add speaker prefix for clarity
332
328
  const speaker = parsed.sessionId;
333
- messageBody = `${speaker}: ${messageBody}`;
329
+ const messageBody = `${speaker}: ${textForAgent}`;
334
330
  // Format agent envelope (following feishu pattern)
335
331
  const body = core.channel.reply.formatAgentEnvelope({
336
332
  channel: "xiaoyi-channel",
@@ -430,7 +426,7 @@ export async function handleXYMessage(params) {
430
426
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
431
427
  await core.channel.reply.withReplyDispatcher({
432
428
  dispatcher,
433
- onSettled: () => {
429
+ onSettled: async () => {
434
430
  log.log(`[BOT] onSettled, steered=${steerState.steered}`);
435
431
  // 🔑 When steered, skip cleanup — the first message's dispatcher is still running
436
432
  if (steerState.steered) {
@@ -448,9 +444,9 @@ export async function handleXYMessage(params) {
448
444
  // signal init complete to release the global dispatch gate
449
445
  // for the next session.
450
446
  const dispatchPromise = runWithSessionContext(sessionContext, async () => {
451
- const isSteerDispatch = isUpdate && !skipReg;
447
+ const isSteerDispatch = isUpdate;
452
448
  log.log(`[ALS-PROOF] bot entered dispatch scope sessionId=${sessionContext.sessionId} taskId=${sessionContext.taskId} isSteer=${isSteerDispatch}`);
453
- log.log(`[BOT-DISPATCH] dispatchReplyFromConfig starting, body.length=${ctxPayload.Body?.length ?? 0}, isSteer=${isSteerDispatch}`);
449
+ log.log(`[BOT-DISPATCH] dispatchReplyFromConfig starting, body.length=${ctxPayload.Body?.length ?? 0}`);
454
450
  try {
455
451
  const result = await core.channel.reply.dispatchReplyFromConfig({
456
452
  ctx: ctxPayload,
@@ -458,13 +454,7 @@ export async function handleXYMessage(params) {
458
454
  dispatcher,
459
455
  replyOptions,
460
456
  });
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
- }
457
+ log.log(`[BOT-DISPATCH] dispatchReplyFromConfig returned, result=${JSON.stringify(result)}`);
468
458
  return result;
469
459
  }
470
460
  catch (dispatchErr) {
@@ -515,19 +505,21 @@ export async function handleXYMessage(params) {
515
505
  function buildXYMediaPayload(mediaList) {
516
506
  const first = mediaList[0];
517
507
  const mediaPaths = mediaList.map((media) => media.path);
508
+ const mediaUrls = mediaList.map((media) => media.uri);
518
509
  const mediaTypes = mediaList.map((media) => media.mimeType).filter(Boolean);
519
510
  return {
520
511
  MediaPath: first?.path,
521
512
  MediaType: first?.mimeType,
513
+ MediaUrl: first?.uri,
522
514
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
515
+ MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
523
516
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
524
517
  };
525
518
  }
526
519
  // ─────────────────────────────────────────────────────────────
527
520
  // Dispatcher updaters (cross-chain taskId bridging)
528
521
  // ─────────────────────────────────────────────────────────────
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.
522
+ // Use globalThis to survive module deduplication
531
523
  const _g = globalThis;
532
524
  if (!_g.__xyDispatcherUpdaters)
533
525
  _g.__xyDispatcherUpdaters = new Map();
@@ -1,6 +1,6 @@
1
1
  import { resolveXYConfig, listXYAccountIds, getDefaultXYAccountId } from "./config.js";
2
2
  import { xyConfigSchema } from "./config-schema.js";
3
- import { xyOutbound } from "./dispatch/outbound.js";
3
+ import { xyOutbound } from "./outbound.js";
4
4
  import { filterToolsByDevice } from "./tools/device-tool-map.js";
5
5
  import { getCurrentSessionContext } from "./tools/session-manager.js";
6
6
  import { logger } from "./utils/logger.js";
@@ -23,16 +23,11 @@ import { getAlarmToolSchemaTool } from "./tools/get-alarm-tool-schema.js";
23
23
  import { getCollectionToolSchemaTool } from "./tools/get-collection-tool-schema.js";
24
24
  import { loginTokenTool } from "./tools/login-token-tool.js";
25
25
  import { agentAsSkillTool } from "./tools/agent-as-skill-tool.js";
26
- import { discoverCrossDevicesTool } from "./tools/discover-cross-devices-tool.js";
27
- import { sendCrossDeviceTaskTool } from "./tools/send-cross-device-task-tool.js";
28
26
  import { displayA2UICardByPathTool } from "./tools/display-a2ui-card-bypath-tool.js";
29
- import { checkPluginPrivilegeTool } from "./tools/check-plugin-privilege-tool.js";
30
27
  import { invokeTool } from "./tools/invoke.js";
31
28
  import { xiaoyiAppendReferenceTool } from "./tools/xiaoyi-append-reference.js";
32
29
  const ALL_TOOLS = [
33
30
  locationTool,
34
- discoverCrossDevicesTool,
35
- sendCrossDeviceTaskTool,
36
31
  displayA2UICardByPathTool,
37
32
  callDeviceTool,
38
33
  getNoteToolSchemaTool,
@@ -51,7 +46,6 @@ const ALL_TOOLS = [
51
46
  saveSelfEvolutionSkillTool,
52
47
  loginTokenTool,
53
48
  agentAsSkillTool,
54
- checkPluginPrivilegeTool,
55
49
  invokeTool,
56
50
  xiaoyiAppendReferenceTool,
57
51
  ];
@@ -158,7 +152,7 @@ export const xyPlugin = {
158
152
  },
159
153
  gateway: {
160
154
  async startAccount(context) {
161
- const { monitorXYProvider } = await import("./dispatch/monitor.js");
155
+ const { monitorXYProvider } = await import("./monitor.js");
162
156
  const { createXyAcpBindingManager } = await import("./acp-session-binding.js");
163
157
  const account = resolveXYConfig(context.cfg);
164
158
  context.setStatus?.({
@@ -49,6 +49,11 @@ export declare const xyConfigSchema: {
49
49
  readonly type: "string";
50
50
  readonly description: "Default session ID for push notifications (used when no target is specified, e.g., in cron jobs)";
51
51
  };
52
+ readonly terminalFrameDelayMs: {
53
+ readonly type: "number";
54
+ readonly description: "Delay in ms before sending terminal frames (completed status + final) so the content artifact clears downstream pipelines first; 0 disables";
55
+ readonly default: 300;
56
+ };
52
57
  };
53
58
  readonly required: readonly ["apiKey", "agentId", "uid", "apiId", "pushId"];
54
59
  };
@@ -50,6 +50,11 @@ export const xyConfigSchema = {
50
50
  type: "string",
51
51
  description: "Default session ID for push notifications (used when no target is specified, e.g., in cron jobs)",
52
52
  },
53
+ terminalFrameDelayMs: {
54
+ type: "number",
55
+ description: "Delay in ms before sending terminal frames (completed status + final) so the content artifact clears downstream pipelines first; 0 disables",
56
+ default: 300,
57
+ },
53
58
  },
54
59
  required: ["apiKey", "agentId", "uid", "apiId", "pushId"],
55
60
  };
@@ -27,6 +27,7 @@ export function resolveXYConfig(cfg) {
27
27
  fileUploadUrl: xyConfig.fileUploadUrl ?? "http://localhost:8767",
28
28
  pushUrl: xyConfig.pushUrl,
29
29
  defaultSessionId: xyConfig.defaultSessionId,
30
+ terminalFrameDelayMs: xyConfig.terminalFrameDelayMs ?? 300,
30
31
  };
31
32
  }
32
33
  /**
@@ -261,6 +261,7 @@ export function markSubagentSpawned(sessionKey) {
261
261
  completionTexts: [],
262
262
  parentSettled: false,
263
263
  finalizationClaimed: false,
264
+ finalDeliveryStarted: false,
264
265
  };
265
266
  states.push(state);
266
267
  }
@@ -367,6 +368,9 @@ export async function deliverSubagentFinalResult(params) {
367
368
  log.error(`[SUBAGENT-FINAL] No cached XY config, cannot deliver final result`);
368
369
  return;
369
370
  }
371
+ // 关闭捕获窗口:此后到达的 sendText 不再并入 final 文本。
372
+ // 等待态在发送后立即清除,迟到的 announce 文本回退 push 通道兜底(不丢消息)。
373
+ state.finalDeliveryStarted = true;
370
374
  const finalText = state.completionTexts.length > 0
371
375
  ? state.completionTexts.join("\n\n")
372
376
  : (params.text || undefined) ?? "子任务已完成";
@@ -16,6 +16,12 @@ export interface SubagentWaitState {
16
16
  completionTexts: string[];
17
17
  parentSettled: boolean;
18
18
  finalizationClaimed: boolean;
19
+ /**
20
+ * final 帧开始交付(deliverSubagentFinalResult 入口)后置位。
21
+ * 捕获窗口以此为准:grace 期间迟到的 announce 文本仍可并入 final;
22
+ * 置位后到达的 sendText 不再捕获(等待态随即清除,回退 push 兜底)。
23
+ */
24
+ finalDeliveryStarted: boolean;
19
25
  }
20
26
  export interface SubagentWaitTransition {
21
27
  state: SubagentWaitState;
@@ -5,7 +5,9 @@
5
5
  // independently of any session. The device processes the command and returns
6
6
  // results through the normal WebSocket connection, so response listening
7
7
  // works the same as for regular tool calls.
8
- import { pushCommand } from "./conversation/outbound-gateway.js";
8
+ import { randomUUID } from "crypto";
9
+ import { XYPushService } from "./push.js";
10
+ import { getAllPushIds } from "./utils/pushid-manager.js";
9
11
  import { logger } from "./utils/logger.js";
10
12
  /**
11
13
  * Send a tool command through the push channel (for cron-triggered tool calls).
@@ -22,8 +24,28 @@ export async function sendCommandViaPush(params) {
22
24
  command.header?.name ??
23
25
  "Command";
24
26
  logger.log(`[CRON-CMD] Sending command via push, intent=${intentName}`);
27
+ // 1. 选 pushId:优先用调用方解析出的设备 pushId(多设备路由正确);
28
+ // 未提供时回退到 getAllPushIds()[0](单设备兼容旧行为)。
29
+ let pushId = config.pushId;
30
+ if (params.pushId) {
31
+ pushId = params.pushId;
32
+ }
33
+ else {
34
+ try {
35
+ const pushIdList = await getAllPushIds();
36
+ if (pushIdList.length > 0) {
37
+ pushId = pushIdList[0];
38
+ }
39
+ }
40
+ catch (error) {
41
+ logger.error("[CRON-CMD] Failed to load pushIds:", error);
42
+ }
43
+ }
44
+ // 2. Build and send push notification with command in directives
45
+ const pushService = new XYPushService(config);
46
+ const sessionId = randomUUID();
25
47
  try {
26
- await pushCommand({ config, command, pushId: params.pushId });
48
+ await pushService.sendPushWithDirectives(pushId, sessionId, [command]);
27
49
  logger.log(`[CRON-CMD] Push sent successfully, intent=${intentName}`);
28
50
  }
29
51
  catch (error) {
@@ -7,11 +7,11 @@ import { callApi } from './call_api.js';
7
7
  import { processText, extractResultText, validateAndTruncateText, parseSecurityResult, handleExecToolInput, handleMessageToolInput, handleOtherToolInput, buildToolOutputPayload, extractInterActionId, extractSessionId } from './utils.js';
8
8
  import { ALLOWED_TOOLS, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, MAX_TEXT_LENGTH, STEER_ABORT_MESSAGE } from './constants.js';
9
9
  import { getCurrentSessionContext } from '../tools/session-manager.js';
10
- import { steer } from '../conversation/steer-service.js';
10
+ import { tryInjectSteer } from './steer-context.js';
11
11
  // 主入口模块
12
12
  export default function register(api) {
13
13
  api.on("before_tool_call", async (event, _ctx) => {
14
- logger.log(`[SENTINEL HOOK] before_tool_call_event toolName: ${event.toolName}`);
14
+ logger.log(`[SENTINEL HOOK] toolCallId=${event.toolCallId}, before_tool_call_event toolName: ${event.toolName}`);
15
15
  // 获取真实sessionID:优先使用ALS中的A2A sessionId,降级到OpenClaw runId或随机值
16
16
  const sessionCtx = getCurrentSessionContext();
17
17
  const sessionId = sessionCtx?.sessionId || event.runId || crypto.randomBytes(16).toString('hex');
@@ -46,7 +46,7 @@ export default function register(api) {
46
46
  return;
47
47
  }
48
48
  try {
49
- logger.log(`[SENTINEL HOOK] after_tool_call_event toolName: ${event.toolName}`);
49
+ logger.log(`[SENTINEL HOOK] toolCallId=${event.toolCallId}, after_tool_call_event toolName: ${event.toolName}`);
50
50
  // 获取真实sessionID:优先使用ALS中的A2A sessionId,降级到OpenClaw runId或随机值
51
51
  const sessionCtx = getCurrentSessionContext();
52
52
  const sessionId = sessionCtx?.sessionId || event.runId || crypto.randomBytes(16).toString('hex');
@@ -81,13 +81,16 @@ export default function register(api) {
81
81
  try {
82
82
  const response = await callApi(outputPayload, api, sessionId);
83
83
  const result = parseSecurityResult(response);
84
- logger.log(`[SENTINEL HOOK] TOOL_OUTPUT response: status=${result.status}.`);
84
+ logger.log(`[SENTINEL HOOK] toolCallId=${event.toolCallId}, TOOL_OUTPUT response: status=${result.status}.`);
85
85
  if (result.status === 'REJECT') {
86
86
  logger.warn('[SENTINEL HOOK] REJECT detected, attempting steer injection');
87
- if (sessionCtx?.sessionId) {
88
- // CSPL steer:经对话管理层直接 steer 当前会话;
89
- // 注入失败即失败,仅记录日志,不做兜底(不新建 dispatcher)
90
- await steer(sessionCtx.sessionId, STEER_ABORT_MESSAGE, 'cspl');
87
+ if (sessionCtx?.sessionId && sessionCtx?.taskId) {
88
+ await tryInjectSteer({
89
+ sessionId: sessionCtx.sessionId,
90
+ taskId: sessionCtx.taskId,
91
+ message: STEER_ABORT_MESSAGE,
92
+ source: 'cspl',
93
+ });
91
94
  }
92
95
  else {
93
96
  logger.warn(`[SENTINEL HOOK] Cannot inject steer: sessionKey=${ctx.sessionKey}, sessionCtx found=${!!sessionCtx}`);
@@ -270,10 +270,10 @@ export function buildToolOutputPayload(taskID, sessionID, funcName, content, too
270
270
  };
271
271
  }
272
272
  // 发送新接口请求并处理响应,返回扫描结果(保留block/steer能力)
273
- async function sendToolInputRequest(payload, api, sessionId) {
273
+ async function sendToolInputRequest(payload, api, sessionId, toolCallId) {
274
274
  const response = await callApi(payload, api, sessionId);
275
275
  const result = parseSecurityResult(response);
276
- logger.log(`[SENTINEL HOOK] TOOL_INPUT response: status=${result.status}`);
276
+ logger.log(`[SENTINEL HOOK] toolCallId=${toolCallId}, TOOL_INPUT response: status=${result.status}`);
277
277
  return result;
278
278
  }
279
279
  // 处理exec工具的TOOL_INPUT数据采集,返回扫描结果(保留block/steer能力)
@@ -309,7 +309,7 @@ export async function handleExecToolInput(event, api, sessionId, taskId) {
309
309
  const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'exec', command, event.toolCallId, interActionID, { file: { type: 'doc', url: obsUrl, hash: fileHash, size: fileSize, body: bodyContent } });
310
310
  logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for file: ${path.basename(filePath)}, body length: ${JSON.stringify(toolInputPayload).length}`);
311
311
  try {
312
- lastResult = await sendToolInputRequest(toolInputPayload, api, sessionId);
312
+ lastResult = await sendToolInputRequest(toolInputPayload, api, sessionId, event.toolCallId);
313
313
  if (lastResult.status === 'REJECT') {
314
314
  return lastResult;
315
315
  }
@@ -330,7 +330,7 @@ export async function handleExecToolInput(event, api, sessionId, taskId) {
330
330
  logger.log('[SENTINEL HOOK] No code files found in command, treating as direct code execution');
331
331
  const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'exec', command, event.toolCallId, interActionID);
332
332
  logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for direct code execution, body length: ${JSON.stringify(toolInputPayload).length}`);
333
- return await sendToolInputRequest(toolInputPayload, api, sessionId);
333
+ return await sendToolInputRequest(toolInputPayload, api, sessionId, event.toolCallId);
334
334
  }
335
335
  }
336
336
  // 处理message工具的TOOL_INPUT数据采集,返回扫描结果(保留block/steer能力)
@@ -344,7 +344,7 @@ export async function handleMessageToolInput(event, api, sessionId, taskId) {
344
344
  const interActionID = extractInterActionId(taskId);
345
345
  const toolInputPayload = buildToolInputPayload(taskId, sessionId, 'message', message, event.toolCallId, interActionID);
346
346
  logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for message, body length: ${JSON.stringify(toolInputPayload).length}`);
347
- return await sendToolInputRequest(toolInputPayload, api, sessionId);
347
+ return await sendToolInputRequest(toolInputPayload, api, sessionId, event.toolCallId);
348
348
  }
349
349
  // 计算项目目录的哈希值(遍历所有文件)
350
350
  export function calculateProjectHash(sourcePath) {
@@ -456,10 +456,10 @@ export async function handleOtherToolInput(event, api, sessionId, taskId) {
456
456
  return null;
457
457
  }
458
458
  logger.log(`[SENTINEL HOOK] Processing other tool input, toolName: ${event.toolName}`);
459
- // 将 params 序列化为 JSON 字符串
460
- const paramsJson = JSON.stringify(params);
459
+ // 将 params 序列化为 JSON 字符串,并限制长度
460
+ const paramsJson = JSON.stringify(params).substring(0, MAX_TEXT_LENGTH);
461
461
  const interActionID = extractInterActionId(taskId);
462
462
  const toolInputPayload = buildToolInputPayload(taskId, sessionId, event.toolName, paramsJson, event.toolCallId, interActionID);
463
463
  logger.log(`[SENTINEL HOOK] Sending TOOL_INPUT for ${event.toolName}, body length: ${JSON.stringify(toolInputPayload).length}`);
464
- return await sendToolInputRequest(toolInputPayload, api, sessionId);
464
+ return await sendToolInputRequest(toolInputPayload, api, sessionId, event.toolCallId);
465
465
  }
@@ -136,19 +136,28 @@ export const xyOutbound = {
136
136
  // 改用 parentSettled 区分:父 run 存活期间的 sendText 是父自己的 message
137
137
  // 工具调用(不捕获、push 照常);父 settle 后命中 waitState 的 sendText
138
138
  // 即为 subagent 完成结果的 announce 投递(捕获 + 吞掉 push)。
139
+ // 捕获窗口以 finalDeliveryStarted 为准(而非 finalizationClaimed):
140
+ // finalizationClaimed 在 markSubagentEnded 时就置位,若 announce 的
141
+ // sendText 晚于 subagent_ended hook 到达(如长文本被 textChunkLimit
142
+ // 分片、投递时序竞争),claim 会提前关闭捕获导致内容丢失——配合
143
+ // push 抑制,丢失是永久的。finalDeliveryStarted 在 grace 结束后、
144
+ // final 帧发送前才置位,grace 期间迟到的文本仍能并入 final。
139
145
  const isFromWaitState = waitState && waitState.parentSettled;
140
146
  if (isFromWaitState) {
141
147
  const log = logger.withContext(waitState.sessionId, waitState.taskId);
142
- if (!waitState.finalizationClaimed) {
148
+ if (!waitState.finalDeliveryStarted) {
143
149
  log.log(`[xyOutbound.sendText] Subagent completion text captured, len=${text?.length ?? 0}`);
144
150
  addCompletionText(targetSessionId, targetTaskId, text);
151
+ log.log(`[xyOutbound.sendText] Subagent announce — swallowing push (result delivered via A2A final frame)`);
152
+ return {
153
+ channel: "xiaoyi-channel",
154
+ messageId: `subagent-swallowed-${Date.now()}`,
155
+ chatId: typeof to === "string" ? to : "",
156
+ };
145
157
  }
146
- log.log(`[xyOutbound.sendText] Subagent announce — swallowing push (result delivered via A2A final frame)`);
147
- return {
148
- channel: "xiaoyi-channel",
149
- messageId: `subagent-swallowed-${Date.now()}`,
150
- chatId: typeof to === "string" ? to : "",
151
- };
158
+ // final 帧已开始交付:不再捕获也不再吞没,fall through 走 push 兜底,
159
+ // 避免迟到的 announce 文本被静默丢弃。
160
+ log.log(`[xyOutbound.sendText] Subagent announce arrived after final delivery started — falling back to push`);
152
161
  }
153
162
  }
154
163
  // ── End subagent text capture ───────────────────────────────