lark-remote 0.5.8 → 0.5.10

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.
@@ -87,6 +87,17 @@ export class QueueManager {
87
87
  interruptedSlots = new Map();
88
88
  /** Per-workspace messageId → replacement closure, set by queue.edit + immediate. */
89
89
  taskReplacements = new Map();
90
+ /**
91
+ * Per-workspace messageId → run 起始 hold,单次消费。
92
+ *
93
+ * queue.immediate 在入口注册、finally 释放;目标任务的 begin 路径照常推进
94
+ * (槽位移除、排队卡翻执行态),但 `forwardToClaude` 在建 run 卡片前会等
95
+ * 当前 lane 占用者的 hold。这样「先 toast、后 run 卡片」与队列链推进速度
96
+ * 无关:run 卡片是点击后最后一条新消息,聊天列表预览才会跟随它的标题变化。
97
+ * hold 按队列 key 存(order.exec 的内部 key 也能命中),通过 executingInfo
98
+ * 找到当前占用者,不需要把队列 key 穿过 router/forwardToClaude 的参数。
99
+ */
100
+ taskRunHolds = new Map();
90
101
  /**
91
102
  * MessageIds of tasks that have actually begun executing (cancellation check
92
103
  * passed, task removed from the queue). Entries are sticky: they remain
@@ -308,6 +319,7 @@ export class QueueManager {
308
319
  // is waiting for instead of reporting "前面还有 0 条".
309
320
  this.executingInfo.set(cwd, {
310
321
  slotId,
322
+ messageId,
311
323
  kind: taskMeta?.kind ?? 'message',
312
324
  label: laneOccupantLabel(taskMeta?.kind ?? 'message', livePreview),
313
325
  preview: livePreview,
@@ -339,6 +351,72 @@ export class QueueManager {
339
351
  getLogger().debug(`[queue-manager] enqueueImmediate cwd=${cwd}`);
340
352
  void task().catch((err) => getLogger().error('[queue-manager] immediate task error:', err));
341
353
  }
354
+ /**
355
+ * 单次:hold 住某个排队任务的 run 起始,直到返回的 release 被调用。
356
+ *
357
+ * 与「hold 任务闭包」不同,这里只挡 `forwardToClaude` 建 run 卡片之前的
358
+ * 那一步:队列槽位照常 begin(排队卡翻执行态、began 记账都发生),所以
359
+ * queue.immediate 的成员资格判断不受影响,只是目标 run 卡片会等 toast。
360
+ * release 幂等;若任务在 begin 前被撤销,release 会清掉未消费的 hold。
361
+ */
362
+ holdTaskRunStart(cwd, messageId) {
363
+ let resolve;
364
+ const hold = {
365
+ promise: new Promise((r) => {
366
+ resolve = r;
367
+ }),
368
+ release: () => resolve(),
369
+ };
370
+ let workspaceHolds = this.taskRunHolds.get(cwd);
371
+ if (!workspaceHolds) {
372
+ workspaceHolds = new Map();
373
+ this.taskRunHolds.set(cwd, workspaceHolds);
374
+ }
375
+ const holds = workspaceHolds.get(messageId);
376
+ if (holds) {
377
+ holds.push(hold);
378
+ }
379
+ else {
380
+ workspaceHolds.set(messageId, [hold]);
381
+ }
382
+ getLogger().debug(`[queue-manager] hold task run start cwd=${cwd} messageId=${messageId}`);
383
+ return () => {
384
+ // Resolve before mutating the map: `awaitTaskRunStart` may already have
385
+ // captured (and deleted) this entry, and the captured promise still needs
386
+ // to settle.
387
+ hold.release();
388
+ const current = this.taskRunHolds.get(cwd)?.get(messageId);
389
+ if (!current)
390
+ return;
391
+ const idx = current.indexOf(hold);
392
+ if (idx >= 0)
393
+ current.splice(idx, 1);
394
+ if (current.length > 0)
395
+ return;
396
+ this.taskRunHolds.get(cwd)?.delete(messageId);
397
+ if (this.taskRunHolds.get(cwd)?.size === 0)
398
+ this.taskRunHolds.delete(cwd);
399
+ };
400
+ }
401
+ /**
402
+ * 等待当前 lane 占用者的 run 起始 hold。由 `forwardToClaude` 在建 run 卡片
403
+ * 前调用;hold 在当前调用里同步摘除,之后才注册的 hold 不会延长本次 run
404
+ * (release 仍会 settle 已捕获的 promise)。
405
+ */
406
+ async awaitTaskRunStart(cwd) {
407
+ const messageId = this.executingInfo.get(cwd)?.messageId;
408
+ if (!messageId)
409
+ return;
410
+ const workspaceHolds = this.taskRunHolds.get(cwd);
411
+ const holds = workspaceHolds?.get(messageId);
412
+ if (!holds || holds.length === 0)
413
+ return;
414
+ workspaceHolds?.delete(messageId);
415
+ if (workspaceHolds?.size === 0)
416
+ this.taskRunHolds.delete(cwd);
417
+ getLogger().debug(`[queue-manager] awaiting task run start cwd=${cwd} messageId=${messageId} holds=${holds.length}`);
418
+ await Promise.all(holds.map((h) => h.promise));
419
+ }
342
420
  /**
343
421
  * One-shot: replace the execution closure of an existing queued task in
344
422
  * place, preserving its queue position (tasks queued behind it still run
@@ -568,7 +646,7 @@ export class QueueManager {
568
646
  ...this.buildQueueActionButtons(cwd, messageId, true),
569
647
  ],
570
648
  });
571
- await this.updateCard(cardMessageId, card);
649
+ await this.updateCardWithRetry(cardMessageId, card);
572
650
  getLogger().debug(`[queue-manager] queue card updated to executing messageId=${messageId}`);
573
651
  }
574
652
  catch (err) {
@@ -597,9 +675,9 @@ export class QueueManager {
597
675
  taskList.splice(index, 1);
598
676
  }
599
677
  this.indexRemove(cwd, messageId);
600
- // Note: queueCardMessages mapping is NOT deleted here.
601
- // The caller (handleQueueCancel) will call updateQueueCardToCancelled
602
- // which will clean up the mapping after updating the card.
678
+ // queueCardMessages mapping 仍不在这里删:卡片更新由 Bridge.removeFromQueue
679
+ // 统一触发(单一收口),updateQueueCardToCancelled 成功/失败后都会清掉映射。
680
+ // 直接调 QueueManager.removeFromQueue 的测试/内部用途不会自动更新卡片。
603
681
  getLogger().info(`[queue-manager] removed from queue cwd=${cwd} messageId=${messageId}`);
604
682
  return true;
605
683
  }
@@ -626,7 +704,7 @@ export class QueueManager {
626
704
  ],
627
705
  });
628
706
  try {
629
- await this.updateCard(cardMessageId, card);
707
+ await this.updateCardWithRetry(cardMessageId, card);
630
708
  getLogger().debug(`[queue-manager] queue card updated to cancelled, messageId=${messageId}`);
631
709
  }
632
710
  catch (err) {
@@ -636,6 +714,21 @@ export class QueueManager {
636
714
  this.queueCardMessages.delete(messageId);
637
715
  }
638
716
  }
717
+ /**
718
+ * 卡片 PATCH 带一次重试:一次网络抖动不该让排队卡永久停在旧状态——映射
719
+ * 会在本次调用结束时被清理,之后没有任何自动修正机会。
720
+ * 第二次仍失败则向上抛,由调用方 catch 记日志(行为与改前一致)。
721
+ */
722
+ async updateCardWithRetry(messageId, card) {
723
+ try {
724
+ await this.updateCard(messageId, card);
725
+ return;
726
+ }
727
+ catch (err) {
728
+ getLogger().warn(`[queue-manager] queue card update failed, retrying once messageId=${messageId}: ${err.message}`);
729
+ }
730
+ await this.updateCard(messageId, card);
731
+ }
639
732
  /** Get task metadata from queue. */
640
733
  getQueuedTask(cwd, messageId) {
641
734
  // O(1) index lookup instead of array `find`.
@@ -22,6 +22,8 @@ const TERMINAL_LABELS = {
22
22
  idle_timeout: '已超时',
23
23
  running: '运行中',
24
24
  finalizing: '完成中',
25
+ // 本轮结束、goal 仍 active:任务没结束,标签不能说"完成"。
26
+ goal_continuing: '目标继续',
25
27
  };
26
28
  export function terminalToLabel(terminal) {
27
29
  return TERMINAL_LABELS[terminal] ?? '运行中';
@@ -37,6 +39,8 @@ export function terminalToColor(terminal) {
37
39
  return 'orange';
38
40
  if (terminal === 'finalizing')
39
41
  return 'orange';
42
+ if (terminal === 'goal_continuing')
43
+ return 'orange';
40
44
  if (terminal === 'running')
41
45
  return 'blue';
42
46
  return 'red';
@@ -1,4 +1,4 @@
1
- import type { RunState, ToolEntry } from './run-state.js';
1
+ import { type RunState, type ToolEntry } from './run-state.js';
2
2
  export interface RunCardRenderOptions {
3
3
  /**
4
4
  * Agent kind for the run card header title (e.g. "Claude · 思考中").
@@ -12,6 +12,13 @@ export interface RunCardRenderOptions {
12
12
  * 无 turn_started → operationKind undefined)同样能拿到按钮。
13
13
  */
14
14
  compactSupported?: boolean;
15
+ /**
16
+ * 卡片没有 `state.sessionId` 时,是否仍有可压缩的会话(bridge 在开跑前从
17
+ * sessionStore 探到的会话)。`false` 表示「本轮没有 init,之前也没有任何
18
+ * 会话」——此时 Compact 按钮点了必然反查不到记录,不渲染。缺省(undefined)
19
+ * 保持原有「按钮可见」语义,供渲染层独立调用方使用。
20
+ */
21
+ sessionFallbackAvailable?: boolean;
15
22
  }
16
23
  type BlockGroup = {
17
24
  kind: 'thinking';
@@ -1,10 +1,11 @@
1
+ import { isRunTerminal, } from './run-state.js';
1
2
  import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, cardButton, agentDisplayName, } from './card-shared.js';
2
3
  import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
3
4
  import { toolBodyMd, toolHeaderText } from './tool-render.js';
4
5
  import { truncateMarkdownTables, CARD_BUDGET_BYTES } from './text-truncate.js';
5
6
  import { truncateUtf8 } from '../common/truncate.js';
6
7
  import { countCardElements, MAX_CARD_ELEMENTS } from './card-budget.js';
7
- import { displayWidth } from '../common/text-width.js';
8
+ import { displayWidth, truncateToDisplayWidth } from '../common/text-width.js';
8
9
  import { isContentFilterFailure } from '../common/turn-failure.js';
9
10
  import { formatClock, formatTimestamp } from './time.js';
10
11
  import { formatUsageStats } from '../router/usage-format.js';
@@ -45,10 +46,75 @@ function shouldShowCompactButton(state, options) {
45
46
  return false;
46
47
  if (options.compactSupported === false)
47
48
  return false;
48
- return (state.terminal === 'done' ||
49
- state.terminal === 'error' ||
50
- state.terminal === 'interrupted' ||
51
- state.terminal === 'idle_timeout');
49
+ // 没有可反查的会话 = 点了必然失败:「/stop 落在 init 之前」的那类卡
50
+ // 上会留一个注定回「该任务已结束」的死按钮。本轮 sessionId 优先;没有时看
51
+ // bridge 在开跑前探到的 store 会话(`sessionFallbackAvailable`,缺省视为可用
52
+ // 以保持渲染层独立调用方的既有语义)。
53
+ if (!state.sessionId && options.sessionFallbackAvailable === false)
54
+ return false;
55
+ // goal 仍 active = 宿主会自动起下一轮,会话永远不 idle,压缩必被拒(DSH 的
56
+ // 手动压缩要求 agent 空闲)。给按钮就是给一个注定失败的点击:用户点
57
+ // compact 只会拿到"还有一轮在跑"。
58
+ if (state.goal?.phase === 'active')
59
+ return false;
60
+ return isRunTerminal(state.terminal);
61
+ }
62
+ /**
63
+ * goal 区块:目标是什么、跑到第几轮、当前什么状态。
64
+ *
65
+ * 为什么放在状态行下面(而不是摘要区):手机上用户只会扫前两屏。goal 是解释
66
+ * "这轮为什么结束却没收尾/为什么不能压缩"的唯一线索,必须靠前。
67
+ *
68
+ * 窄屏约束:每行一个 div,段间用 ` · `,不做 auto+grow 并排。
69
+ */
70
+ const GOAL_INLINE_WIDTH = 64;
71
+ const GOAL_FULL_MAX_BYTES = 2_400;
72
+ /** 目标阶段的短标签。 */
73
+ function goalPhaseLabel(goal) {
74
+ if (goal.phase === 'active')
75
+ return '目标进行中';
76
+ if (goal.phase === 'paused')
77
+ return '目标已暂停';
78
+ if (goal.phase === 'blocked')
79
+ return '目标被阻断';
80
+ return '目标已完成';
81
+ }
82
+ /**
83
+ * Build the goal block (empty when the session has no goal).
84
+ *
85
+ * 状态来源是宿主的 `goal` 投影/`goal/change`,不是桥的推断:把"我这一轮结束
86
+ * 了"当任务结束,就是"卡片写完成、任务还在跑"那类缺陷的来源。
87
+ */
88
+ function buildGoalSection(state) {
89
+ const goal = state.goal;
90
+ if (!goal)
91
+ return [];
92
+ const elements = [];
93
+ const round = `第 ${goal.roundsStarted}/${goal.maxGoalRounds} 轮`;
94
+ elements.push(markdownDiv(`🎯 **${goalPhaseLabel(goal)}** · ${round}`));
95
+ // 目标全文可能有换行/超长:行内压成单行并截断,全文进折叠面板。
96
+ const singleLine = goal.objective.replace(/\s+/gu, ' ').trim();
97
+ const inline = displayWidth(singleLine) <= GOAL_INLINE_WIDTH
98
+ ? singleLine
99
+ : truncateToDisplayWidth(singleLine, GOAL_INLINE_WIDTH);
100
+ elements.push(markdownDiv(`目标:${inline}`, 'notation'));
101
+ if (inline !== singleLine) {
102
+ elements.push(collapsibleMarkdownPanel({
103
+ title: '🎯 目标全文',
104
+ expanded: false,
105
+ border: 'grey',
106
+ content: truncateUtf8(goal.objective, GOAL_FULL_MAX_BYTES),
107
+ textSize: 'notation',
108
+ }));
109
+ }
110
+ if (goal.phase === 'blocked' && goal.blockedReason) {
111
+ elements.push(markdownDiv(`⛔ 阻断:${goal.blockedReason.code} — ${goal.blockedReason.message}`, 'notation'));
112
+ }
113
+ if (goal.phase === 'active' && state.terminal === 'goal_continuing') {
114
+ // 这句话就是整张卡的核心信息:本轮结束 ≠ 任务结束。
115
+ elements.push(markdownDiv(`_本轮已结束;DSH 会自动开始第 ${goal.roundsStarted + 1} 轮_`, 'notation'));
116
+ }
117
+ return elements;
52
118
  }
53
119
  /**
54
120
  * Wrap text block in a collapsible panel with timestamp in the header.
@@ -397,9 +463,22 @@ function assembleRunCard(state, options, elements) {
397
463
  template: headerTemplate(state),
398
464
  title: { content: headerTitle(state, options), tag: 'plain_text' },
399
465
  },
400
- body: { elements },
466
+ // goal 区块紧随状态行(所有预算档位都以此为第一元素),让"目标是什么/
467
+ // 第几轮"出现在折叠面板之前;它只有 2–3 个元素,兜底档位也扛得住。
468
+ body: { elements: insertAfterStatusRow(elements, buildGoalSection(state)) },
401
469
  };
402
470
  }
471
+ /**
472
+ * 把目标区块插到状态行之后。所有预算档位的 elements 都以 statusRow 开头;
473
+ * 空数组时原样返回,避免把 `undefined` 塞进飞书卡片数组。
474
+ */
475
+ function insertAfterStatusRow(elements, extra) {
476
+ if (extra.length === 0)
477
+ return elements;
478
+ if (elements.length === 0)
479
+ return extra;
480
+ return [elements[0], ...extra, ...elements.slice(1)];
481
+ }
403
482
  /**
404
483
  * Build a budget-fallback tier (degraded or extreme): keep the last N thinking
405
484
  * blocks and last N tools, truncate text/thinking per tier, then render in
@@ -964,7 +1043,14 @@ function buildSummaryContent(state, options = {}) {
964
1043
  model: state.model,
965
1044
  costUsd: state.costUsd,
966
1045
  reasoningTokens: state.reasoningTokens,
967
- }, { showResult: true, result });
1046
+ }, {
1047
+ showResult: true,
1048
+ result,
1049
+ // 本轮结束但目标继续:不能报"已完成",也不能说"出错"。
1050
+ ...(state.terminal === 'goal_continuing'
1051
+ ? { completedLabel: '⏳ 本轮结束 · 目标仍在继续' }
1052
+ : {}),
1053
+ });
968
1054
  elements.push(markdownDiv(usageStatsStr + empty));
969
1055
  // 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
970
1056
  elements.push(...buildBackgroundJobsSection(state));
@@ -1086,8 +1172,13 @@ function statusTagLabel(terminal) {
1086
1172
  /** CardKit 2.0 header title — agent-aware via options.agentKind. */
1087
1173
  function headerTitle(state, options = {}) {
1088
1174
  const agent = agentDisplayName(options.agentKind ?? 'claude');
1089
- if (state.terminal === 'done')
1090
- return `✅ ${agent} · 已完成`;
1175
+ // 本轮 turn 结束但 goal 仍 active:这是"一轮结束",不是"任务完成"。标题
1176
+ // 必须说清楚,否则用户会以为活儿干完了。
1177
+ if (state.terminal === 'goal_continuing')
1178
+ return `⏳ ${agent} · 本轮结束(目标继续)`;
1179
+ if (state.terminal === 'done') {
1180
+ return state.goal?.phase === 'complete' ? `✅ ${agent} · 目标已完成` : `✅ ${agent} · 已完成`;
1181
+ }
1091
1182
  if (state.terminal === 'error')
1092
1183
  return `⚠️ ${agent} · 出错`;
1093
1184
  if (state.terminal === 'interrupted')
@@ -1,5 +1,22 @@
1
- import type { AgentEvent, ApprovalView, ToolHint } from '../runner/index.js';
2
- export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout';
1
+ import type { AgentEvent, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
2
+ export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout'
3
+ /**
4
+ * 本轮 turn 已结束,但会话的 goal 仍 active:宿主会自动开始下一轮,任务没有
5
+ * 完成,卡片不得写"完成"。与 done 的差别只在语义与渲染,
6
+ * 卡片同样停止更新。
7
+ */
8
+ | 'goal_continuing';
9
+ /**
10
+ * 已收尾的 run 终态集合(卡片不再更新)。
11
+ *
12
+ * 单一来源:事件消费守卫、可压缩记账白名单、Compact 按钮可见性三处共用,
13
+ * 禁止各自再抄一份字面量——三处漂移过一次(goal_continuing 只进白名单不进
14
+ * 按钮判据),新增终态时必漏一处的风险是结构性的。
15
+ */
16
+ export declare const RUN_TERMINAL_STATES: readonly ["done", "error", "interrupted", "idle_timeout", "goal_continuing"];
17
+ export type RunFinalTerminal = (typeof RUN_TERMINAL_STATES)[number];
18
+ /** 该状态是否已收尾(非 running/finalizing)。 */
19
+ export declare function isRunTerminal(terminal: RunTerminal): terminal is RunFinalTerminal;
3
20
  export type RunFooter = 'thinking' | 'tool_running' | 'streaming' | null;
4
21
  /** 中断来源(interrupted 终态的原因),供卡片如实归因。 */
5
22
  export type InterruptedReason = 'approval_timeout' | 'user_stop' | 'approval_cancelled';
@@ -161,6 +178,16 @@ export interface RunState {
161
178
  status?: string;
162
179
  progress?: string;
163
180
  }>;
181
+ /**
182
+ * 会话级长期目标(DSH goal)。跨 turn 生命周期:goal 仍 active 时,本轮结束
183
+ * 只是"一轮结束",宿主会自动续跑,卡片必须显示目标是什么、跑到第几轮。
184
+ */
185
+ goal?: GoalView;
186
+ /**
187
+ * goal 是否被成功观测过。`false` = 探测失败,渲染层不得据此断言"没有目标/
188
+ * 已完成"(把读不到当成没有,就退回了"任务在跑、卡片写完成"的旧缺陷)。
189
+ */
190
+ goalKnown?: boolean;
164
191
  }
165
192
  export interface FinishMeta {
166
193
  resultSubtype?: 'success' | 'error' | 'interrupted';
@@ -3,6 +3,24 @@ const MAX_REASONING_CHARS = 4_000;
3
3
  const MAX_TEXT_CHARS = 12_000;
4
4
  const MAX_TOOL_DETAIL_CHARS = 3_000;
5
5
  const MAX_BLOCKS = 24;
6
+ /**
7
+ * 已收尾的 run 终态集合(卡片不再更新)。
8
+ *
9
+ * 单一来源:事件消费守卫、可压缩记账白名单、Compact 按钮可见性三处共用,
10
+ * 禁止各自再抄一份字面量——三处漂移过一次(goal_continuing 只进白名单不进
11
+ * 按钮判据),新增终态时必漏一处的风险是结构性的。
12
+ */
13
+ export const RUN_TERMINAL_STATES = [
14
+ 'done',
15
+ 'error',
16
+ 'interrupted',
17
+ 'idle_timeout',
18
+ 'goal_continuing',
19
+ ];
20
+ /** 该状态是否已收尾(非 running/finalizing)。 */
21
+ export function isRunTerminal(terminal) {
22
+ return RUN_TERMINAL_STATES.includes(terminal);
23
+ }
6
24
  export function createInitialRunState(runId) {
7
25
  return {
8
26
  runId,
@@ -17,16 +35,17 @@ export function reduceRunState(state, event) {
17
35
  // 只有终态才阻止事件处理。finalizing 是非终态,
18
36
  // 仍需累积 assistant 内容(thinking、text、tool_use、后台任务输出)。
19
37
  const terminal = state.terminal;
20
- if (terminal === 'done' ||
21
- terminal === 'error' ||
22
- terminal === 'interrupted' ||
23
- terminal === 'idle_timeout') {
38
+ if (isRunTerminal(terminal)) {
24
39
  return state;
25
40
  }
26
41
  if (event.type === 'system')
27
42
  return reduceSystemEvent(state, event);
28
43
  if (event.type === 'run_status')
29
44
  return reduceRunStatusEvent(state, event);
45
+ if (event.type === 'goal_state') {
46
+ // 覆盖写:每次观测都是宿主的整份快照(phase/轮次/目标全文),不做增量。
47
+ return { ...state, goal: event.goal ?? undefined, goalKnown: event.known };
48
+ }
30
49
  if (event.type === 'background_jobs')
31
50
  return reduceBackgroundJobsEvent(state, event);
32
51
  if (event.type === 'result')
package/dist/index.js CHANGED
@@ -532,16 +532,17 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
532
532
  const content = router.expandAliasMessage(msg.content);
533
533
  if (isStopCommand(content)) {
534
534
  void (async () => {
535
- const stopped = await bridge.interruptCurrentRun({
535
+ const interrupt = await bridge.interruptCurrentRunDetailed({
536
536
  userId: msg.userId,
537
537
  chatId: msg.chatId,
538
538
  });
539
- if (!stopped) {
540
- await bridge.sendResult({ text: '当前没有运行中的进程' }, {
541
- userId: msg.userId,
542
- chatId: msg.chatId,
543
- messageId: msg.messageId,
544
- });
539
+ const ctx = { userId: msg.userId, chatId: msg.chatId, messageId: msg.messageId };
540
+ if (!interrupt.stopped) {
541
+ await bridge.sendResult({ text: '当前没有运行中的进程' }, ctx);
542
+ }
543
+ else if ((interrupt.stoppedCount ?? 1) > 1) {
544
+ // 并行 workspace 时 /stop 停全部,必须让用户看到数量。
545
+ await bridge.sendResult({ text: `✅ 已停止 ${interrupt.stoppedCount} 个运行中的任务` }, ctx);
545
546
  }
546
547
  })().catch((err) => logger.error('[control] /stop failed:', err));
547
548
  return;