wezard 1.3.11 → 1.3.13

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.
@@ -328,6 +328,43 @@ const cleanUserText = (raw) => {
328
328
  return stripped;
329
329
  };
330
330
  const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
331
+ // 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
332
+ // `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
333
+ // <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
334
+ const AGENT_NOTICE_RE = /^\s*(\[(?:Framework )?Auto-Notification\][^\n]*)/;
335
+ // Agent/Task 派发是不是后台的。后台派发的 result 是 spawn 句柄 (300ms 就回),
336
+ // 不代表子agent 结束 —— in-flight 记账必须按这个标志分流。
337
+ const isBackgroundAgentCall = (input) => {
338
+ const i = input;
339
+ return i?.run_in_background === true || i?.runInBackground === true;
340
+ };
341
+ // 派发的可辨识名 —— 完成通知里没有 callId, 只有 agent 名/描述, 用它对账。
342
+ const agentCallLabel = (input) => {
343
+ const i = input;
344
+ const v = [i?.name, i?.subagent_type, i?.description].find((x) => typeof x === "string" && x.trim());
345
+ return typeof v === "string" ? v.trim() : "";
346
+ };
347
+ // 4-gram Jaccard。中文按字符切窗即可, 无需分词; 只服务一个判断: 这两份终稿是不是
348
+ // 同一份答案 (平台把"子agent 完成"展开成结果 + 通知两条注入时, 模型会被唤醒两次,
349
+ // 各写一遍内容几乎相同的终稿 —— 逐字相等的比较拦不住)。
350
+ const shinglesOf = (s) => {
351
+ const t = s.replace(/\s+/g, "");
352
+ const out = new Set();
353
+ for (let i = 0; i + 4 <= t.length; i += 1)
354
+ out.add(t.slice(i, i + 4));
355
+ return out;
356
+ };
357
+ const textSimilarity = (x, y) => {
358
+ const a = shinglesOf(x);
359
+ const b = shinglesOf(y);
360
+ if (a.size === 0 || b.size === 0)
361
+ return x.replace(/\s+/g, "") === y.replace(/\s+/g, "") ? 1 : 0;
362
+ const ratio = a.size / b.size;
363
+ if (ratio > 2 || ratio < 0.5)
364
+ return 0; // 长度差一倍以上不可能是同一份答案
365
+ const inter = [...a].reduce((n, g) => (b.has(g) ? n + 1 : n), 0);
366
+ return inter / (a.size + b.size - inter);
367
+ };
331
368
  const renderToolInput = (input) => {
332
369
  try {
333
370
  const json = JSON.stringify(input ?? {}, null, 0);
@@ -458,7 +495,14 @@ const renderLine = (raw, deps) => {
458
495
  // a single styled line. quiet: 这是 subagent 的回执, 不单独推给聊天。
459
496
  const taskMatch = c.match(TASK_NOTIF_RE);
460
497
  if (taskMatch && taskMatch[1]) {
461
- out.push({ kind: "skill_output", body: renderTaskNotification(taskMatch[1]), quiet: true });
498
+ out.push({ kind: "skill_output", body: renderTaskNotification(taskMatch[1]), quiet: true, agentDone: taskMatch[1] });
499
+ return out;
500
+ }
501
+ // 同一语义的无标签形态 (codebuddy 平台注入)。必须绕开下面的 includeUser 门 ——
502
+ // 它是后台派发唯一的结束信号, 被丢掉 openAgents 就永远等不到销账。
503
+ const notice = c.match(AGENT_NOTICE_RE)?.[1];
504
+ if (notice) {
505
+ out.push({ kind: "skill_output", body: `🏁 ${notice.trim()}`, quiet: true, agentDone: notice });
462
506
  return out;
463
507
  }
464
508
  // /goal activation — non-isMeta 形态兜底 (marker 格式漂移防御), emit
@@ -475,12 +519,12 @@ const renderLine = (raw, deps) => {
475
519
  // cover reloads or replays; the user message itself always can.
476
520
  if (deps.isKeepalivePing?.(text))
477
521
  return [{ kind: "keepalive_start", body: text }];
478
- if (!deps.includeUser)
479
- return [];
480
522
  if (deps.isOwnInject(text))
481
523
  return []; // dedupe WeCom→CLI echo
482
524
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
483
- out.push({ kind: "user_text", body: quoted });
525
+ // includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
526
+ // onItem 消费完边界语义后自己丢掉。
527
+ out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser });
484
528
  }
485
529
  else if (Array.isArray(c)) {
486
530
  for (const b of c) {
@@ -1455,6 +1499,17 @@ export const startMirror = (deps) => {
1455
1499
  const SOFT_TURN_END_DEFAULT_CLAUDE = 4_000;
1456
1500
  const SOFT_TURN_END_DEFAULT_CB = 1_000;
1457
1501
  const softTurnEndMsFor = (a) => cfg.wrc.mirror.softTurnEndMs ?? (backendForPath(a.jsonlPath).name === "codebuddy" ? SOFT_TURN_END_DEFAULT_CB : SOFT_TURN_END_DEFAULT_CLAUDE);
1502
+ /** 未返回派发的挂账上限。到点即销账 —— 结束信号可能永远不来 (后台 agent 挂了 /
1503
+ * 平台没发通知 / 前台 result 因中断丢失), 而 CLI 侧起的 turn 没有 hardTimer,
1504
+ * 没有这个上限就是永久静默。 */
1505
+ const OPEN_AGENT_TTL_MS = 15 * 60_000;
1506
+ /** 子agent 刚返回后的宽限窗口。此刻立刻收口投出去的还是派发前那句独白 —— 父侧
1507
+ * 正要写真正的答复。宽限期内父侧一有产出就撤销重排, 真的没有下文才以独白兜底。 */
1508
+ const AGENT_RETURN_GRACE_MS = 15_000;
1509
+ /** 同一 query 轮次内, 与上一份终稿的近似度超过此值即判为重复投递。 */
1510
+ const DUP_CONCLUSION_RATIO = 0.8;
1511
+ /** 重复终稿抑制的时间上限 —— 轮次相同但隔了很久的答复仍算新答复。 */
1512
+ const DUP_CONCLUSION_WINDOW_MS = 10 * 60_000;
1458
1513
  const STREAM_SOFT_CAP = 18_000;
1459
1514
  const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1460
1515
  const turnRegistry = new Map();
@@ -1862,6 +1917,12 @@ export const startMirror = (deps) => {
1862
1917
  // 其它所有 item 只写入 turn detail store。
1863
1918
  // 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
1864
1919
  const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
1920
+ // 父 agent 自己的发言 —— 只有这些能推翻"上一条消息说完了"的收口意图。其余 item
1921
+ // (tool_result / 完成通知 / 平台注入的 user 行) 都不是父侧产出, 只把欠着的那次
1922
+ // 软收口往后顺延, 不销欠账。见 onItem 的 softOwed 分支。
1923
+ // turn_end / goal_start 同样销欠账 —— 一个是权威收口 (软形态会在紧随其后的分支里
1924
+ // 重新记上欠账), 一个是整个 turn 交给 goal 模式接管。
1925
+ const SOFT_END_CANCELLERS = new Set(["text", "thinking", "tool_use", "turn_end", "goal_start"]);
1865
1926
  // ── CoT 进度行 (brief 气泡) ─────────────────────────────────────────
1866
1927
  // 一轮里正文要等 final text 才落地, 中间几十秒到几分钟气泡只有一个 "…"。这里把最新的
1867
1928
  // thinking / 工具调用覆盖进这条还没收口的气泡, 让群里看得到进展。三条硬约束:
@@ -1986,6 +2047,7 @@ export const startMirror = (deps) => {
1986
2047
  };
1987
2048
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
1988
2049
  const turnId = newTurnId();
2050
+ a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
1989
2051
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
1990
2052
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
1991
2053
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
@@ -2053,6 +2115,18 @@ export const startMirror = (deps) => {
2053
2115
  // • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → 把暂存 header
2054
2116
  // 拼到 body 前缀一并 standalone, 一条消息含详情入口 + 正文。
2055
2117
  // • body 为空 → 一个字不发, 顺手清掉 header, 不留"只有链接"的空消息。
2118
+ // • 同一 query 轮次内的第二份近似终稿 → 只记进 turn/detail, 不再投递 (见下)。
2119
+ //
2120
+ // 重复终稿: 平台把"一个后台子agent 完成"展开成结果 + 完成通知两条独立 user 注入,
2121
+ // 模型被唤醒两次、各写一遍完整终稿。briefConcluded 只在 turn 内幂等 —— turn 一收,
2122
+ // 下一条 text 就是 BRIEF_TURN_OPENER, 补开新 turn 再投一次。判据只能跨 turn:
2123
+ // 同一 query 轮次 (queryEpoch 未推进 = 中间没有真实用户输入) + 与上一份终稿高度
2124
+ // 近似 = 重复。气泡仍开的 turn 不在此列 —— 那是 WeCom 自己发起的一轮, 必须收口。
2125
+ const isDuplicateConclusion = (a, body) => a.lastConcludedBody !== undefined
2126
+ && a.lastConcludedEpoch === (a.queryEpoch ?? 0)
2127
+ && Date.now() - (a.lastConcludedAt ?? 0) < DUP_CONCLUSION_WINDOW_MS
2128
+ && (!a.briefBubble || a.briefBubble.done)
2129
+ && textSimilarity(a.lastConcludedBody, body) >= DUP_CONCLUSION_RATIO;
2056
2130
  const concludeBriefTurn = (a, body) => {
2057
2131
  const turnId = a.briefTurnId;
2058
2132
  if (!turnId || a.briefConcluded)
@@ -2061,7 +2135,16 @@ export const startMirror = (deps) => {
2061
2135
  a.pendingBriefHeader = undefined;
2062
2136
  return;
2063
2137
  }
2138
+ if (isDuplicateConclusion(a, body)) {
2139
+ a.briefConcluded = true; // 本轮已"定稿", 只是不投递
2140
+ a.pendingBriefHeader = undefined;
2141
+ log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
2142
+ return;
2143
+ }
2064
2144
  a.briefConcluded = true;
2145
+ a.lastConcludedBody = body;
2146
+ a.lastConcludedEpoch = a.queryEpoch ?? 0;
2147
+ a.lastConcludedAt = Date.now();
2065
2148
  if (a.briefBubble && !a.briefBubble.done) {
2066
2149
  void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2067
2150
  a.pendingBriefHeader = undefined;
@@ -2092,6 +2175,16 @@ export const startMirror = (deps) => {
2092
2175
  a.briefConcluded = false;
2093
2176
  a.briefLastText = undefined;
2094
2177
  a.pendingBriefHeader = undefined;
2178
+ // 收口 = 欠账两清。前台派发的 result 不会再落回这一轮; 后台派发跨轮存活, 由完成
2179
+ // 通知 / TTL 销账 (voidTurnAgents)。挂着的软收口计时器一并撤掉 —— 它到点会去收
2180
+ // 「当前 turn」, 那时的当前 turn 已经是下一轮了。
2181
+ if (a.softEnd) {
2182
+ clearTimeout(a.softEnd);
2183
+ a.softEnd = undefined;
2184
+ }
2185
+ a.softOwed = false;
2186
+ a.softDeferred = false;
2187
+ voidTurnAgents(a);
2095
2188
  closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
2096
2189
  clearCot(a);
2097
2190
  };
@@ -2393,17 +2486,70 @@ export const startMirror = (deps) => {
2393
2486
  }
2394
2487
  // tool_use / tool_result / user_text / turn_usage: recorded to detail store; no bubble.
2395
2488
  };
2489
+ // ── 未返回派发的账本 (openAgents) ───────────────────────────────────
2490
+ // 记账/销账/挂账上限。软收口的"独白 vs 正文"判据全靠它 —— 判据本身只是一个静默
2491
+ // 计时器, 分不出"我在等子agent"和"我说完了"。
2492
+ // TTL 到期的挂账作废。结束信号可能永远不来, 上限是唯一的兜底。
2493
+ const pruneOpenAgents = (a) => {
2494
+ const now = Date.now();
2495
+ for (const [id, v] of a.openAgents ?? []) {
2496
+ if (now - v.at < OPEN_AGENT_TTL_MS)
2497
+ continue;
2498
+ a.openAgents.delete(id);
2499
+ log.info({ sessionId: a.sessionId, callId: id, label: v.label }, "open agent expired — TTL reached");
2500
+ }
2501
+ };
2502
+ // 轮次作废: 已经过去的那一轮派发的前台 Agent/Task, result 不可能再落回, 直接销账。
2503
+ // 两条边界都要按 turnId 认人 —— 平台注入的 user 行 (子agent 结果回灌) 与本轮的完成
2504
+ // 通知都会走到这里, 一刀切会把**本轮正在跑**的前台派发也销掉, guard 当场失效。
2505
+ // 后台派发与轮次无关: 只有完成通知 / TTL 有资格销它。
2506
+ const voidTurnAgents = (a) => {
2507
+ for (const [id, v] of a.openAgents ?? []) {
2508
+ if (!v.background && v.turnId !== a.briefTurnId)
2509
+ a.openAgents.delete(id);
2510
+ }
2511
+ };
2512
+ // 后台派发销账。通知里没有 callId, 只有 agent 名/摘要: 先按派发时留的 label 命中,
2513
+ // 命中不了就销最早的一笔 —— N 次派发对 N 条通知, 账本终会归零。
2514
+ const clearBackgroundAgent = (a, notice) => {
2515
+ const open = [...(a.openAgents ?? [])].filter(([, v]) => v.background);
2516
+ if (open.length === 0)
2517
+ return;
2518
+ const hit = open.find(([, v]) => v.label && notice.includes(v.label))
2519
+ ?? open.reduce((oldest, e) => (e[1].at < oldest[1].at ? e : oldest));
2520
+ a.openAgents.delete(hit[0]);
2521
+ log.info({ sessionId: a.sessionId, callId: hit[0], label: hit[1].label, left: a.openAgents.size }, "background agent done — cleared by completion notice");
2522
+ };
2523
+ // 下一次软收口尝试的等待时长: 账上还有未返回的派发 → 等到最早那笔 TTL 到期
2524
+ // (期间任何信号都会重排); 账已清零 → 宽限窗口 (子agent 刚回, 父侧马上要说话)。
2525
+ const deferredSoftDelay = (a) => {
2526
+ const oldest = [...(a.openAgents?.values() ?? [])]
2527
+ .reduce((m, v) => (m === undefined || v.at < m ? v.at : m), undefined);
2528
+ return oldest === undefined
2529
+ ? Math.max(softTurnEndMsFor(a), AGENT_RETURN_GRACE_MS)
2530
+ : Math.max(1_000, OPEN_AGENT_TTL_MS - (Date.now() - oldest));
2531
+ };
2532
+ const armSoftEnd = (a, ms) => {
2533
+ if (a.softEnd)
2534
+ clearTimeout(a.softEnd);
2535
+ a.softEnd = setTimeout(() => fireSoftTurnEnd(a), ms);
2536
+ };
2396
2537
  // 软收口到点: 静默期内没有新 item, 确认这一轮真的结束了。
2397
2538
  const fireSoftTurnEnd = (a) => {
2398
2539
  a.softEnd = undefined;
2399
- // 子agent 会话调用中 (Agent/Task 已派发、result 未落回主会话): 上一条文本只是
2400
- // 普通独白, 不能当 final 正文收口 —— 静默期往往正是父 turn 阻塞在子agent 上的
2401
- // 表象。defer 不重挂: 子agent 返回后父转录会写最终文本, 自带新的软收口;
2402
- // 无后续文本的死角由 HARD_TIMEOUT_MS 兜底。
2540
+ pruneOpenAgents(a);
2541
+ // 子agent 调用中 (已派发、结束信号未到): 上一条文本只是普通独白, 不能当 final
2542
+ // 正文收口 —— 静默期往往正是父 turn 阻塞在子agent 上的表象。
2543
+ // 必须重挂: 后台派发的结束信号是平台注入的 user 行/通知, 它们不会让父转录再写
2544
+ // 一段文本, 光靠"下一次软收口"等不来。重挂的到点时刻见 deferredSoftDelay。
2403
2545
  if (a.openAgents && a.openAgents.size > 0) {
2546
+ a.softDeferred = true;
2547
+ armSoftEnd(a, deferredSoftDelay(a));
2404
2548
  log.info({ sessionId: a.sessionId, turnId: a.briefTurnId, open: [...a.openAgents.keys()] }, "soft turn_end deferred — subagent in flight, keeping monologue");
2405
2549
  return;
2406
2550
  }
2551
+ a.softOwed = false;
2552
+ a.softDeferred = false;
2407
2553
  log.debug({ sessionId: a.sessionId, turnId: a.briefTurnId }, "soft turn_end confirmed");
2408
2554
  if (a.goalActive) {
2409
2555
  handleGoalItem(a, { kind: "turn_end" });
@@ -2475,10 +2621,15 @@ export const startMirror = (deps) => {
2475
2621
  // its reply) mirror normally instead of being eaten by the stale swallow.
2476
2622
  if (item.kind === "user_text") {
2477
2623
  endKeepaliveSwallow(a);
2478
- // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的 Agent/Task 到此作废,
2624
+ // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
2479
2625
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
2480
- a.openAgents?.clear();
2626
+ voidTurnAgents(a);
2627
+ a.queryEpoch = (a.queryEpoch ?? 0) + 1;
2481
2628
  }
2629
+ // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。放在
2630
+ // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
2631
+ if (item.kind === "skill_output" && item.agentDone)
2632
+ clearBackgroundAgent(a, item.agentDone);
2482
2633
  if (a.keepaliveQuiet || a.keepaliveByContent) {
2483
2634
  const id = a.keepaliveTurnId;
2484
2635
  if (id) {
@@ -2502,12 +2653,26 @@ export const startMirror = (deps) => {
2502
2653
  }
2503
2654
  return;
2504
2655
  }
2505
- // 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
2506
- // 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
2656
+ // 父 agent 自己的新产出到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认
2657
+ // 的软收口。硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
2658
+ //
2659
+ // 只有父侧发言 (SOFT_END_CANCELLERS) 才有资格推翻收口意图。tool_result / 后台完成
2660
+ // 通知 / 平台注入的 user 行都不是父侧发言 —— 它们只让欠着的那次收口往后顺延。
2661
+ // 一视同仁地抹掉欠账, 子agent 一返回独白就永远等不到收口 (CLI 侧 turn 没有
2662
+ // hardTimer 兜底 —— 那只挂在 WeCom 起的气泡上)。
2507
2663
  if (a.softEnd) {
2508
2664
  clearTimeout(a.softEnd);
2509
2665
  a.softEnd = undefined;
2510
2666
  }
2667
+ if (SOFT_END_CANCELLERS.has(item.kind)) {
2668
+ a.softOwed = false;
2669
+ a.softDeferred = false;
2670
+ }
2671
+ else if (a.softOwed)
2672
+ armSoftEnd(a, a.softDeferred ? deferredSoftDelay(a) : softTurnEndMsFor(a));
2673
+ // includeUser=false 下的 user 行只作边界信号 (上面已清账/推进轮次), 不渲染。
2674
+ if (item.kind === "user_text" && item.quiet)
2675
+ return;
2511
2676
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
2512
2677
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
2513
2678
  // 那就变回已下线的 thinkStyle 了。
@@ -2517,17 +2682,21 @@ export const startMirror = (deps) => {
2517
2682
  return;
2518
2683
  }
2519
2684
  if (item.kind === "turn_end" && item.soft === true) {
2520
- a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
2685
+ a.softOwed = true;
2686
+ a.softDeferred = false;
2687
+ armSoftEnd(a, softTurnEndMsFor(a));
2521
2688
  return;
2522
2689
  }
2523
2690
  // codebuddy: ExitPlanMode 若被本地先答, result 落盘即作废挂着的计划审批卡
2524
2691
  // (卡 pending 期间 model 阻塞在本地对话框, 不可能有其它 tool_result)。
2525
2692
  if (item.kind === "tool_result") {
2526
2693
  mootMirrorPlan(a.sessionId);
2527
- // Agent/Task 的 result 落回主转录 = 子agent 会话结束、控制权返回主会话。
2694
+ // 前台 Agent/Task 的 result 落回主转录 = 子agent 会话结束、控制权返回主会话。
2528
2695
  // 任何结果 (含非 Agent/Task 工具) 都可能恰好顶着某 callId, 只删命中项即可。
2529
- if (item.toolUseId)
2530
- a.openAgents?.delete(item.toolUseId);
2696
+ // 后台派发例外: 它的 result 是 300ms 就回的 spawn 句柄, 销账等于把 guard 关掉。
2697
+ const open = item.toolUseId ? a.openAgents?.get(item.toolUseId) : undefined;
2698
+ if (open && !open.background)
2699
+ a.openAgents.delete(item.toolUseId);
2531
2700
  }
2532
2701
  // Record tool_use signatures unconditionally (before any state branching),
2533
2702
  // so flushBeforeCard's poll-drain can detect that the to-be-approved tool
@@ -2553,9 +2722,14 @@ export const startMirror = (deps) => {
2553
2722
  description: typeof inp?.description === "string" && inp.description ? inp.description : undefined,
2554
2723
  at: Date.now(),
2555
2724
  }];
2556
- // 派发即记 in-flight (key=callId)。result 落盘时由 tool_result 分支清除。
2725
+ // 派发即记 in-flight (key=callId)。销账路径按前后台分流, 见 openAgents 注释。
2557
2726
  if (c.toolUseId) {
2558
- (a.openAgents ??= new Map()).set(c.toolUseId, Date.now());
2727
+ (a.openAgents ??= new Map()).set(c.toolUseId, {
2728
+ at: Date.now(),
2729
+ background: isBackgroundAgentCall(c.input),
2730
+ label: agentCallLabel(c.input),
2731
+ turnId: a.briefTurnId,
2732
+ });
2559
2733
  }
2560
2734
  }
2561
2735
  // codebuddy 的 ExitPlanMode 完全不过 PreToolUse hook (实测: 由