@ynhcj/xiaoyi-channel 0.0.217-beta → 0.0.218-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 (48) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +133 -3
  3. package/dist/src/bot.js +111 -30
  4. package/dist/src/channel.js +2 -0
  5. package/dist/src/compaction-provider.d.ts +50 -0
  6. package/dist/src/compaction-provider.js +202 -0
  7. package/dist/src/cron-query-handler.d.ts +20 -0
  8. package/dist/src/cron-query-handler.js +481 -91
  9. package/dist/src/cron-recovery.d.ts +25 -0
  10. package/dist/src/cron-recovery.js +587 -0
  11. package/dist/src/cspl/call_api.d.ts +7 -1
  12. package/dist/src/cspl/call_api.js +51 -6
  13. package/dist/src/cspl/sentinel_hook.js +3 -3
  14. package/dist/src/cspl/skill_scope_hook.d.ts +17 -0
  15. package/dist/src/cspl/skill_scope_hook.js +125 -0
  16. package/dist/src/cspl/upload_file.js +5 -2
  17. package/dist/src/cspl/utils.d.ts +16 -6
  18. package/dist/src/cspl/utils.js +113 -7
  19. package/dist/src/file-download.d.ts +1 -0
  20. package/dist/src/file-download.js +1 -0
  21. package/dist/src/formatter.d.ts +36 -0
  22. package/dist/src/formatter.js +74 -0
  23. package/dist/src/log-reporter/index.js +7 -3
  24. package/dist/src/monitor.js +49 -8
  25. package/dist/src/outbound.d.ts +7 -0
  26. package/dist/src/outbound.js +62 -6
  27. package/dist/src/provider.js +90 -2
  28. package/dist/src/reply-dispatcher.d.ts +2 -0
  29. package/dist/src/reply-dispatcher.js +127 -48
  30. package/dist/src/skill-retriever/hooks.d.ts +17 -0
  31. package/dist/src/skill-retriever/hooks.js +123 -3
  32. package/dist/src/skill-retriever/tool-search.js +13 -11
  33. package/dist/src/steer-queue.d.ts +1 -0
  34. package/dist/src/steer-queue.js +9 -0
  35. package/dist/src/subagent-wait-state.d.ts +7 -0
  36. package/dist/src/subagent-wait-state.js +14 -0
  37. package/dist/src/task-manager.d.ts +9 -3
  38. package/dist/src/task-manager.js +48 -16
  39. package/dist/src/tools/call-phone-tool.d.ts +0 -1
  40. package/dist/src/tools/call-phone-tool.js +5 -8
  41. package/dist/src/tools/hmos-cli.d.ts +1 -1
  42. package/dist/src/tools/hmos-cli.js +4 -4
  43. package/dist/src/tools/send-email-tool.js +1 -0
  44. package/dist/src/tools/xiaoyi-append-reference.d.ts +47 -0
  45. package/dist/src/tools/xiaoyi-append-reference.js +107 -0
  46. package/dist/src/tools/xiaoyi-gui-tool.js +10 -1
  47. package/dist/src/websocket.js +10 -2
  48. package/package.json +3 -2
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
- declare const pluginEntry: ReturnType<typeof definePluginEntry>;
3
- export default pluginEntry;
1
+ import { type OpenClawPluginDefinition } from "openclaw/plugin-sdk/core";
2
+ declare const plugin: OpenClawPluginDefinition;
3
+ export default plugin;
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
2
  import { xiaoyiProvider } from "./src/provider.js";
3
+ import { xiaoyiCompactionProvider } from "./src/compaction-provider.js";
3
4
  import { xyPlugin } from "./src/channel.js";
4
5
  import registerSentinelHook from "./src/cspl/sentinel_hook.js";
6
+ import registerSkillScopeHook from "./src/cspl/skill_scope_hook.js";
5
7
  import { setXYRuntime } from "./src/runtime.js";
6
8
  import { markCronToolCall, clearCronToolCall, getCurrentSessionContext } from "./src/tools/session-manager.js";
7
9
  import { configManager } from "./src/utils/config-manager.js";
@@ -11,7 +13,10 @@ import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-
11
13
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
12
14
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
13
15
  import { registerCLIHook } from "./src/tools/hmos-cli.js";
16
+ import { recoverCronState } from "./src/cron-recovery.js";
14
17
  import { writeSkillUsage } from "./src/utils/skills-logger.js";
18
+ import { markSubagentSpawned, markSubagentEnded, getCachedXYConfig, } from "./src/subagent-wait-state.js";
19
+ import { logger } from "./src/utils/logger.js";
15
20
  /**
16
21
  * Parse a file path string to detect if it refers to a SKILL.md file within
17
22
  * a skills directory. Returns the skill name (parent directory) if so.
@@ -205,7 +210,121 @@ function readJobIdFromResult(result) {
205
210
  }
206
211
  return undefined;
207
212
  }
213
+ // ── Gateway startup: cron state recovery ────────────────────────────────────
214
+ /**
215
+ * Register the gateway_start hook for cron state recovery.
216
+ *
217
+ * On gateway startup, checks .openclaw/cron/ for legacy JSON/JSONL files
218
+ * and migrates them into the SQLite state database:
219
+ * - Reads legacy jobs.json + jobs-state.json → imports into cron_jobs table
220
+ * - Reads legacy runs/*.jsonl → imports into cron_run_logs table
221
+ * - Archives migrated files with .migrated suffix
222
+ *
223
+ * Pattern follows legacy-store-migration.ts and legacy-run-log-migration.ts:
224
+ * check → load → import into SQLite → archive old files.
225
+ */
226
+ function registerCronRecoveryHook(api) {
227
+ api.on("gateway_start", async (_event, _ctx) => {
228
+ const logTag = "[CRON-RECOVERY-HOOK]";
229
+ const startTime = Date.now();
230
+ logger.log(`${logTag} ═══════════════════════════════════════════`);
231
+ logger.log(`${logTag} gateway_start fired — checking for legacy cron files`);
232
+ logger.log(`${logTag} Timestamp: ${new Date().toISOString()}`);
233
+ logger.log(`${logTag} Plugin registration mode: ${api.registrationMode ?? "unknown"}`);
234
+ let result;
235
+ try {
236
+ result = await recoverCronState();
237
+ }
238
+ catch (err) {
239
+ const errMsg = err instanceof Error ? err.message : String(err);
240
+ const errStack = err instanceof Error ? err.stack : undefined;
241
+ logger.error(`${logTag} cron state recovery threw: ${errMsg}`);
242
+ if (errStack)
243
+ logger.error(`${logTag} Stack: ${errStack}`);
244
+ // Don't let a recovery failure block gateway startup.
245
+ logger.log(`${logTag} Recovery failed after ${Date.now() - startTime}ms — gateway startup continues`);
246
+ return;
247
+ }
248
+ const elapsed = Date.now() - startTime;
249
+ if (result.recovered) {
250
+ logger.log(`${logTag} ✅ Migration performed successfully in ${elapsed}ms:` +
251
+ ` storeMigrated=${result.storeMigrated}, ` +
252
+ ` runLogFilesImported=${result.runLogFilesImported}`);
253
+ }
254
+ else {
255
+ logger.log(`${logTag} ℹ️ No legacy cron files migrated in ${elapsed}ms ` +
256
+ `(nothing to migrate or database unavailable)`);
257
+ }
258
+ // Log diagnostics summary
259
+ const warnings = result.diagnostics.filter((d) => d.includes("skipping") || d.includes("locked") || d.includes("unavailable"));
260
+ const errors = result.diagnostics.filter((d) => d.includes("error") || d.includes("failed") || d.includes("Failed"));
261
+ if (warnings.length > 0) {
262
+ logger.warn(`${logTag} ${warnings.length} warning(s) from migration:`);
263
+ for (const w of warnings) {
264
+ logger.warn(`${logTag} ⚠ ${w}`);
265
+ }
266
+ }
267
+ if (errors.length > 0) {
268
+ logger.error(`${logTag} ${errors.length} error(s) from migration:`);
269
+ for (const e of errors) {
270
+ logger.error(`${logTag} ✗ ${e}`);
271
+ }
272
+ }
273
+ if (warnings.length === 0 && errors.length === 0) {
274
+ logger.log(`${logTag} All diagnostics clean, no warnings or errors`);
275
+ }
276
+ logger.log(`${logTag} ═══════════════════════════════════════════`);
277
+ });
278
+ }
279
+ function registerSubagentHooks(api) {
280
+ // subagent_spawned: fires after a subagent run is successfully registered.
281
+ // We increment the expected completion count so that onIdle knows to wait.
282
+ api.on("subagent_spawned", async (_event, ctx) => {
283
+ const requesterSessionKey = ctx?.requesterSessionKey;
284
+ if (!requesterSessionKey)
285
+ return;
286
+ const count = markSubagentSpawned(requesterSessionKey);
287
+ if (count > 0) {
288
+ logger.log(`[XY-SUBAGENT] spawned, requesterSessionKey=${requesterSessionKey.slice(0, 30)}, expected=${count}`);
289
+ }
290
+ });
291
+ // subagent_ended: fires when a subagent run terminates (complete/error/killed).
292
+ // This is the PRIMARY delivery tracking mechanism. When all expected
293
+ // subagents have ended and parent has settled, we finalize the A2A session.
294
+ api.on("subagent_ended", async (event, ctx) => {
295
+ try {
296
+ const requesterSessionKey = ctx?.requesterSessionKey;
297
+ if (!requesterSessionKey) {
298
+ logger.log(`[XY-SUBAGENT-END] no requesterSessionKey in ctx`);
299
+ return;
300
+ }
301
+ const transition = markSubagentEnded(requesterSessionKey);
302
+ 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}`);
303
+ if (transition?.shouldFinalize) {
304
+ logger.log(`[XY-SUBAGENT-END] Starting finalization...`);
305
+ const config = getCachedXYConfig();
306
+ if (!config) {
307
+ logger.error(`[XY-SUBAGENT-END] No cached XY config, cannot deliver final result`);
308
+ return;
309
+ }
310
+ const { deliverSubagentFinalResult } = await import("./src/outbound.js");
311
+ logger.log(`[XY-SUBAGENT-END] Using cached XY config`);
312
+ await deliverSubagentFinalResult({
313
+ config: config,
314
+ state: transition.state,
315
+ reason: "all-subagents-ended-after-parent-settled",
316
+ });
317
+ logger.log(`[XY-SUBAGENT-END] Finalized A2A session after all subagents ended`);
318
+ }
319
+ }
320
+ catch (err) {
321
+ logger.error(`[XY-SUBAGENT-END] Error in subagent_ended hook:`, err);
322
+ }
323
+ });
324
+ }
208
325
  function registerFullHooks(api) {
326
+ // SUBAGENT HOOKS: track subagent spawn/end lifecycle for session keep-alive
327
+ registerSubagentHooks(api);
209
328
  // SKILL RETRIEVER HOOK: before_prompt_build hook
210
329
  const pluginConfig = api.pluginConfig || {};
211
330
  const skillRetrieverConfig = normalizeToolRetrieverConfig({
@@ -216,10 +335,13 @@ function registerFullHooks(api) {
216
335
  timeoutMs: pluginConfig.skillRetrieverTimeoutMs ?? 1000,
217
336
  });
218
337
  const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
219
- api.on("before_prompt_build", beforePromptBuildHandler);
338
+ api.on("before_prompt_build", async (event, ctx) => {
339
+ logger.log(`[BEFORE_PROMPT_BUILD] hook fired, sessionKey=${ctx.sessionKey || "undefined"}, sessionId=${ctx.sessionId || "undefined"}`);
340
+ return beforePromptBuildHandler(event, ctx);
341
+ });
220
342
  registerSelfEvolutionToolResultNudge(api);
221
343
  }
222
- const pluginEntry = definePluginEntry({
344
+ const plugin = definePluginEntry({
223
345
  id: "xiaoyi-channel",
224
346
  name: "Xiaoyi Channel",
225
347
  description: "Xiaoyi channel plugin - Xiaoyi A2A protocol integration",
@@ -227,6 +349,10 @@ const pluginEntry = definePluginEntry({
227
349
  // Always register the provider so wrapStreamFn/prepareExtraParams work
228
350
  // in ALL registration modes (not just "full").
229
351
  api.registerProvider(xiaoyiProvider);
352
+ // Register the compaction provider so openclaw's safeguard hook uses
353
+ // our summarization path (which injects x-hag-trace-id) instead of the
354
+ // built-in LLM path that bypasses wrapStreamFn.
355
+ api.registerCompactionProvider(xiaoyiCompactionProvider);
230
356
  if (api.registrationMode === "cli-metadata") {
231
357
  return;
232
358
  }
@@ -244,13 +370,17 @@ const pluginEntry = definePluginEntry({
244
370
  registerFullHooks(api);
245
371
  // CSPL sentinel hook: before_tool_call + after_tool_call security scanning
246
372
  registerSentinelHook(api);
373
+ // CSPL skill scope hook: before_install security scanning
374
+ registerSkillScopeHook(api);
247
375
  // Cron detection hook: marks toolCallIds from cron sessions
248
376
  registerCronDetectionHook(api);
249
377
  // CLI exec hook: intercepts built-in exec for HarmonyOS CLI skill tools
250
378
  registerCLIHook(api);
379
+ // Cron recovery hook: prunes stale cron-push-map and pushData on gateway startup
380
+ registerCronRecoveryHook(api);
251
381
  // Skills diagnostic hook: log skill usage (detected via SKILL.md reads)
252
382
  registerSkillsDiagnosticHook(api);
253
383
  }
254
384
  },
255
385
  });
256
- export default pluginEntry;
386
+ export default plugin;
package/dist/src/bot.js CHANGED
@@ -15,7 +15,9 @@ import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
15
15
  import { saveRuntimeInfo } from "./utils/runtime-manager.js";
16
16
  import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
17
17
  import { setCsplSteerContext } from "./cspl/steer-context.js";
18
+ import { resolveActiveEmbeddedRunSessionId, queueAgentHarnessMessage, } from "openclaw/plugin-sdk/agent-harness-runtime";
18
19
  import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
20
+ import { registerSessionKeyMapping, getWaitState, hasWaitState, markParentSettled, cacheXYConfig, } from "./subagent-wait-state.js";
19
21
  import { logger } from "./utils/logger.js";
20
22
  /**
21
23
  * Handle an incoming A2A message.
@@ -164,6 +166,7 @@ export async function handleXYMessage(params) {
164
166
  const runCrossTaskContext = extractRunCrossTaskContext(parsed.parts);
165
167
  // Resolve configuration (needed for status updates)
166
168
  const config = resolveXYConfig(cfg);
169
+ cacheXYConfig(config);
167
170
  // ✅ Resolve agent route (following feishu pattern)
168
171
  // accountId is "default" for XY (single account mode)
169
172
  // Use sessionId as peer.id to ensure all messages in the same session share context
@@ -177,6 +180,16 @@ export async function handleXYMessage(params) {
177
180
  },
178
181
  });
179
182
  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
+ }
180
193
  // Check for ACP runtime binding on this A2A conversation
181
194
  const runtimeRoute = resolveRuntimeConversationBindingRoute({
182
195
  route,
@@ -241,20 +254,18 @@ export async function handleXYMessage(params) {
241
254
  log.error(`[BOT] Failed to patch session model override:`, patchErr);
242
255
  }
243
256
  }
244
- // 🔑 发送初始状态更新(仅首条消息,steer 不重复发送)
245
- if (!isUpdate) {
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
- });
257
- }
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
+ });
258
269
  }
259
270
  // Extract text and files from parts
260
271
  const text = extractTextFromParts(parsed.parts);
@@ -280,10 +291,8 @@ export async function handleXYMessage(params) {
280
291
  }
281
292
  }
282
293
  // ── Build message and dispatch via auto-reply pipeline ──────────
283
- // OpenClaw's auto-reply pipeline (src/auto-reply/reply/agent-runner.ts)
284
- // detects active runs and handles steer injection natively — the channel
285
- // does not need its own steer detection or polling logic.
286
- // File download
294
+ // 首条消息和 steer 回退都走此统一 dispatch 路径。
295
+ // File download — for both first message and steer fallthrough
287
296
  let mediaPayload = {};
288
297
  if (!skipReg || isUpdate) {
289
298
  const fileParts = extractFileParts(parsed.parts);
@@ -299,13 +308,35 @@ export async function handleXYMessage(params) {
299
308
  textForAgent = `${textForAgent}${fileHint}`;
300
309
  log.log(`[BOT] Steer: appended file paths to text`);
301
310
  }
311
+ // 🔑 Direct steer: bypass dispatchReplyFromConfig entirely and inject the
312
+ // message directly into the active embedded agent run. This avoids the
313
+ // per-session ReplyOperation lock in admitReplyTurn which would otherwise
314
+ // block the steer message until the first message completes — making steer
315
+ // indistinguishable from a followup.
316
+ if (isUpdate && !skipReg && route.sessionKey) {
317
+ const activeSessionId = resolveActiveEmbeddedRunSessionId(route.sessionKey);
318
+ if (activeSessionId) {
319
+ log.log(`[BOT-STEER] Direct steer attempt: activeSessionId=${activeSessionId}, textLen=${textForAgent.length}`);
320
+ const queued = queueAgentHarnessMessage(activeSessionId, textForAgent, {
321
+ steeringMode: "all",
322
+ });
323
+ if (queued) {
324
+ log.log(`[BOT-STEER] Direct steer succeeded — message injected into active run`);
325
+ // Steer message taskId refCount is no longer needed since we skip the dispatcher.
326
+ decrementTaskIdRef(parsed.sessionId);
327
+ return;
328
+ }
329
+ log.log(`[BOT-STEER] Direct steer failed (queued=false), falling through to dispatchReplyFromConfig`);
330
+ }
331
+ else {
332
+ log.log(`[BOT-STEER] No active embedded run session for key=${route.sessionKey}, falling through to dispatchReplyFromConfig`);
333
+ }
334
+ }
302
335
  // Resolve envelope format options (following feishu pattern)
303
336
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
304
337
  // Build message body with speaker prefix (following feishu pattern)
305
- let messageBody = textForAgent;
306
- // Add speaker prefix for clarity
307
338
  const speaker = parsed.sessionId;
308
- messageBody = `${speaker}: ${messageBody}`;
339
+ const messageBody = `${speaker}: ${textForAgent}`;
309
340
  // Format agent envelope (following feishu pattern)
310
341
  const body = core.channel.reply.formatAgentEnvelope({
311
342
  channel: "xiaoyi-channel",
@@ -314,12 +345,20 @@ export async function handleXYMessage(params) {
314
345
  envelope: envelopeOptions,
315
346
  body: messageBody,
316
347
  });
348
+ // 🔑 Steer messages use /steer prefix + CommandSource "native" to trigger
349
+ // the native slash command fast path, which calls handleSteerCommand →
350
+ // queueEmbeddedAgentMessageWithOutcomeAsync before admitReplyTurn blocks.
351
+ const steerCommandBody = isUpdate ? `/steer ${textForAgent}` : textForAgent;
352
+ if (isUpdate) {
353
+ log.log(`[BOT-STEER] Dispatching via /steer fast path, sessionKey=${route.sessionKey}, cmdLen=${steerCommandBody.length}`);
354
+ }
317
355
  // ✅ Finalize inbound context (following feishu pattern)
318
356
  // Use route.accountId and route.sessionKey instead of parsed fields
319
357
  const ctxPayload = core.channel.reply.finalizeInboundContext({
320
358
  Body: body,
321
- RawBody: textForAgent,
322
- CommandBody: textForAgent,
359
+ RawBody: steerCommandBody,
360
+ CommandBody: steerCommandBody,
361
+ ...(isUpdate ? { CommandSource: "native" } : {}),
323
362
  From: parsed.sessionId,
324
363
  To: parsed.sessionId, // ✅ Simplified: use sessionId as target (context is managed by SessionKey)
325
364
  SessionKey: route.sessionKey, // ✅ Use route.sessionKey
@@ -343,15 +382,33 @@ export async function handleXYMessage(params) {
343
382
  // response and cleanup — the first message's dispatcher handles those.
344
383
  const steerState = { steered: isUpdate };
345
384
  // 🔑 创建dispatcher
346
- log.log(`[BOT-DISPATCHER] Creating reply dispatcher`);
385
+ log.log(`[BOT-DISPATCHER] Creating reply dispatcher, isSteer=${isUpdate}, sessionKey=${route.sessionKey}`);
347
386
  // Cleanup: 必须在 onIdle 内部执行(参见 reply-dispatcher.ts 中 onIdleComplete 的注释)
387
+ // CSPL steer injections (skipRegistration=true) do NOT call registerTaskId,
388
+ // so they must skip decrementTaskIdRef. All other paths (original message +
389
+ // WebSocket steer that fell through to the /steer fast path) DO increment
390
+ // refCount and must decrement here.
348
391
  let cleaned = false;
349
392
  const cleanup = () => {
350
393
  if (cleaned)
351
394
  return;
352
395
  cleaned = true;
353
- log.log(`[BOT] Cleanup started`);
354
- decrementTaskIdRef(parsed.sessionId);
396
+ log.log(`[BOT] Cleanup started, steered=${steerState.steered}, skipReg=${skipReg}`);
397
+ if (!skipReg) {
398
+ // Check for pending subagent wait on this session
399
+ const pendingWait = getWaitState(parsed.sessionId, parsed.taskId) ??
400
+ (hasWaitState(parsed.sessionId) ? { deliveredCompletions: 0, expectedCompletions: 1 } : null);
401
+ if (pendingWait && pendingWait.deliveredCompletions < pendingWait.expectedCompletions) {
402
+ // Subagent wait active — skip cleanup, session stays alive
403
+ log.log(`[BOT] Cleanup suppressed — subagent wait active on session, taskId=${parsed.taskId}`);
404
+ cleaned = false;
405
+ return;
406
+ }
407
+ decrementTaskIdRef(parsed.sessionId);
408
+ }
409
+ else {
410
+ log.log(`[BOT] CSPL steer cleanup, skipping decrementTaskIdRef (never incremented)`);
411
+ }
355
412
  log.log(`[BOT] Cleanup completed`);
356
413
  };
357
414
  const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval, updateFallbackTaskId } = createXYReplyDispatcher({
@@ -388,13 +445,34 @@ export async function handleXYMessage(params) {
388
445
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
389
446
  await core.channel.reply.withReplyDispatcher({
390
447
  dispatcher,
391
- onSettled: () => {
448
+ onSettled: async () => {
392
449
  log.log(`[BOT] onSettled, steered=${steerState.steered}`);
393
450
  // 🔑 When steered, skip cleanup — the first message's dispatcher is still running
394
451
  if (steerState.steered) {
395
452
  log.log(`[BOT] Steered dispatch settled, skipping cleanup`);
396
453
  return;
397
454
  }
455
+ // Subagent wait state: if parent has pending subagents, mark settled
456
+ // and defer finalization. Cleanup is suppressed so the session stays alive.
457
+ const pendingWait = getWaitState(parsed.sessionId, parsed.taskId);
458
+ if (pendingWait && !pendingWait.parentSettled) {
459
+ const transition = markParentSettled(parsed.sessionId, parsed.taskId);
460
+ if (transition?.shouldFinalize) {
461
+ // All completions arrived before parent settled → finalize now
462
+ const { deliverSubagentFinalResult } = await import("./outbound.js");
463
+ await deliverSubagentFinalResult({
464
+ config,
465
+ state: transition.state,
466
+ reason: "all-subagent-results-delivered-before-parent-settled",
467
+ });
468
+ log.log(`[BOT] Subagent wait complete on parent settled; final response delivered`);
469
+ }
470
+ else {
471
+ log.log(`[BOT] Subagent wait active, preserving session context for completion`);
472
+ }
473
+ dispatcherUpdaters.delete(parsed.sessionId);
474
+ return;
475
+ }
398
476
  // cleanup 已由 onIdleComplete 在 onIdle 的 finally 中执行。
399
477
  // onSettled 不做任何清理(直接在这里清理会发生 race condition)。
400
478
  dispatcherUpdaters.delete(parsed.sessionId);
@@ -406,7 +484,8 @@ export async function handleXYMessage(params) {
406
484
  // signal init complete to release the global dispatch gate
407
485
  // for the next session.
408
486
  const dispatchPromise = runWithSessionContext(sessionContext, async () => {
409
- log.log(`[ALS-PROOF] bot entered dispatch scope sessionId=${sessionContext.sessionId} taskId=${sessionContext.taskId} isSteer=false`);
487
+ const isSteerDispatch = isUpdate && !skipReg;
488
+ log.log(`[ALS-PROOF] bot entered dispatch scope sessionId=${sessionContext.sessionId} taskId=${sessionContext.taskId} isSteer=${isSteerDispatch}`);
410
489
  log.log(`[BOT-DISPATCH] dispatchReplyFromConfig starting, body.length=${ctxPayload.Body?.length ?? 0}`);
411
490
  try {
412
491
  const result = await core.channel.reply.dispatchReplyFromConfig({
@@ -466,19 +545,21 @@ export async function handleXYMessage(params) {
466
545
  function buildXYMediaPayload(mediaList) {
467
546
  const first = mediaList[0];
468
547
  const mediaPaths = mediaList.map((media) => media.path);
548
+ const mediaUrls = mediaList.map((media) => media.uri);
469
549
  const mediaTypes = mediaList.map((media) => media.mimeType).filter(Boolean);
470
550
  return {
471
551
  MediaPath: first?.path,
472
552
  MediaType: first?.mimeType,
553
+ MediaUrl: first?.uri,
473
554
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
555
+ MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
474
556
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
475
557
  };
476
558
  }
477
559
  // ─────────────────────────────────────────────────────────────
478
560
  // Dispatcher updaters (cross-chain taskId bridging)
479
561
  // ─────────────────────────────────────────────────────────────
480
- // Use globalThis to survive module deduplication — provider.ts may load a
481
- // different copy of bot.ts, so a plain module-level Map would be two objects.
562
+ // Use globalThis to survive module deduplication
482
563
  const _g = globalThis;
483
564
  if (!_g.__xyDispatcherUpdaters)
484
565
  _g.__xyDispatcherUpdaters = new Map();
@@ -28,6 +28,7 @@ import { sendCrossDeviceTaskTool } from "./tools/send-cross-device-task-tool.js"
28
28
  import { displayA2UICardByPathTool } from "./tools/display-a2ui-card-bypath-tool.js";
29
29
  import { checkPluginPrivilegeTool } from "./tools/check-plugin-privilege-tool.js";
30
30
  import { invokeTool } from "./tools/invoke.js";
31
+ import { xiaoyiAppendReferenceTool } from "./tools/xiaoyi-append-reference.js";
31
32
  const ALL_TOOLS = [
32
33
  locationTool,
33
34
  discoverCrossDevicesTool,
@@ -52,6 +53,7 @@ const ALL_TOOLS = [
52
53
  agentAsSkillTool,
53
54
  checkPluginPrivilegeTool,
54
55
  invokeTool,
56
+ xiaoyiAppendReferenceTool,
55
57
  ];
56
58
  /**
57
59
  * Xiaoyi Channel Plugin for OpenClaw.
@@ -0,0 +1,50 @@
1
+ interface CompactionConfig {
2
+ uid: string;
3
+ baseUrl: string;
4
+ modelName: string;
5
+ apiKey?: string;
6
+ }
7
+ /**
8
+ * Snapshot of the resolved session context, captured from the last normal
9
+ * LLM request before compaction is triggered. When available, the
10
+ * CompactionProvider reuses the same A2A traceId/sessionId so the
11
+ * compaction summarization request is linked to the same user session
12
+ * in backend tracing.
13
+ */
14
+ interface CompactionSessionSnapshot {
15
+ traceId: string;
16
+ sessionId: string;
17
+ interactionId: string;
18
+ deviceType?: string;
19
+ appVer?: string;
20
+ sdkApiVersion?: string;
21
+ /** Auth headers from options.headers in the last normal request. */
22
+ requestHeaders?: Record<string, string>;
23
+ /** API key from the last normal request (for Bearer token auth). */
24
+ apiKey?: string;
25
+ /** Model-level headers from resolveDynamicModel (streamSimple adds these). */
26
+ modelHeaders?: Record<string, string>;
27
+ }
28
+ /** Store config captured from prepareExtraParams so summarize() can read it. */
29
+ export declare function setCompactionConfig(config: CompactionConfig | null): void;
30
+ /**
31
+ * Store a snapshot of the resolved session headers captured from the
32
+ * most recent wrapStreamFn call. This lets the CompactionProvider reuse
33
+ * the A2A traceId/sessionId during compaction summarization.
34
+ */
35
+ export declare function setCompactionSessionSnapshot(snapshot: CompactionSessionSnapshot | null): void;
36
+ export declare const xiaoyiCompactionProvider: {
37
+ id: string;
38
+ label: string;
39
+ summarize(params: {
40
+ messages: unknown[];
41
+ signal?: AbortSignal;
42
+ customInstructions?: string;
43
+ summarizationInstructions?: {
44
+ identifierPolicy?: string;
45
+ identifierInstructions?: string;
46
+ };
47
+ previousSummary?: string;
48
+ }): Promise<string>;
49
+ };
50
+ export {};