@ynhcj/xiaoyi-channel 0.0.224-next → 0.0.226-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.
@@ -115,16 +115,18 @@ export const xyOutbound = {
115
115
  chatId: typeof to === "string" ? to : "",
116
116
  };
117
117
  }
118
- // ── Subagent completion text capture ─────────────────────────
118
+ // ── Subagent completion text capture + push 抑制 ─────────────
119
119
  // Subagent completions may arrive here when openclaw's announce
120
120
  // flow delivers through the channel outbound. We capture the text
121
121
  // for the final A2A response but do NOT track delivery count here.
122
122
  // Delivery tracking is done by the subagent_ended hook (index.ts).
123
123
  //
124
- // IMPORTANT: We do NOT return early here. The OpenClaw core's announce
125
- // flow needs sendText to complete normally (push delivery) to avoid
126
- // triggering the direct-primary fallback retry loop. Text is captured
127
- // for the A2A final response, but push delivery proceeds normally.
124
+ // 正常对话的 subagent announce 一律不发 push:结果由保活机制经
125
+ // A2A final 帧交付,push webhook 通知冗余。返回合成成功(与 cron
126
+ // 吞没路径同理),保证 openclaw durable-delivery 记账正常,避免
127
+ // 触发 direct-primary fallback 重试。
128
+ // cron 的 announce 不受此分支影响:cron sendText 无 target
129
+ // (DEFAULT_PUSH_MARKER),且 cron 会话不存在 wait state,push 照常。
128
130
  if (to && to !== DEFAULT_PUSH_MARKER) {
129
131
  const [targetSessionId, targetTaskId] = String(to).split("::");
130
132
  const waitState = getWaitState(targetSessionId, targetTaskId);
@@ -132,15 +134,21 @@ export const xyOutbound = {
132
134
  // 同样携带父会话 ALS 上下文(resolveTarget 据此增强 target),ALS guard
133
135
  // 会把 announce 误判为父 turn 自己的 sendText 而漏捕获。
134
136
  // 改用 parentSettled 区分:父 run 存活期间的 sendText 是父自己的 message
135
- // 工具调用(不捕获);父 settle 后命中 waitState 的 sendText 即为
136
- // subagent 完成结果的 announce 投递(捕获)。
137
+ // 工具调用(不捕获、push 照常);父 settle 后命中 waitState 的 sendText
138
+ // 即为 subagent 完成结果的 announce 投递(捕获 + 吞掉 push)。
137
139
  const isFromWaitState = waitState && waitState.parentSettled;
138
- if (isFromWaitState && !waitState.finalizationClaimed) {
140
+ if (isFromWaitState) {
139
141
  const log = logger.withContext(waitState.sessionId, waitState.taskId);
140
- log.log(`[xyOutbound.sendText] Subagent completion text captured, len=${text?.length ?? 0}`);
141
- addCompletionText(targetSessionId, targetTaskId, text);
142
- // Fall through to normal push delivery — core needs sendText to
143
- // complete normally to avoid retry/announce-failure loops.
142
+ if (!waitState.finalizationClaimed) {
143
+ log.log(`[xyOutbound.sendText] Subagent completion text captured, len=${text?.length ?? 0}`);
144
+ addCompletionText(targetSessionId, targetTaskId, text);
145
+ }
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
+ };
144
152
  }
145
153
  }
146
154
  // ── End subagent text capture ───────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.224-next",
3
+ "version": "0.0.226-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",