lark-remote 0.5.19 → 0.5.20

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 (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +1 -1
  10. package/dist/bridge/index.d.ts +97 -153
  11. package/dist/bridge/index.js +182 -809
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +109 -97
  18. package/dist/card/card-budget.js +1 -1
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +34 -93
  24. package/dist/card/run-state.d.ts +1 -1
  25. package/dist/card/run-state.js +28 -90
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +1 -1
  28. package/dist/card/usage-format.d.ts +12 -0
  29. package/dist/card/usage-format.js +8 -3
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/config/codex-config.js +130 -106
  33. package/dist/config/index.d.ts +16 -2
  34. package/dist/config/index.js +23 -8
  35. package/dist/connector/index.js +6 -6
  36. package/dist/error-classification.js +3 -2
  37. package/dist/inbound/placeholder.d.ts +1 -1
  38. package/dist/inbound/placeholder.js +1 -1
  39. package/dist/index.js +203 -133
  40. package/dist/logger/index.js +6 -6
  41. package/dist/message-handlers.d.ts +0 -5
  42. package/dist/message-handlers.js +1 -1
  43. package/dist/model-catalog.d.ts +2 -2
  44. package/dist/model-catalog.js +3 -8
  45. package/dist/platform/identity.js +1 -1
  46. package/dist/platform/path.d.ts +1 -1
  47. package/dist/platform/path.js +1 -1
  48. package/dist/platform/sleep-blocker.js +1 -1
  49. package/dist/platform/terminator-win32.js +2 -1
  50. package/dist/router/commands/resume.js +2 -20
  51. package/dist/router/config/dsh.d.ts +4 -8
  52. package/dist/router/config/dsh.js +3 -10
  53. package/dist/router/config/kimi.js +1 -1
  54. package/dist/router/config/types.d.ts +1 -1
  55. package/dist/router/config-diff.js +1 -1
  56. package/dist/router/index.d.ts +56 -11
  57. package/dist/router/index.js +219 -200
  58. package/dist/runner/claude/session.d.ts +1 -1
  59. package/dist/runner/claude/session.js +6 -4
  60. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  61. package/dist/runner/codex/app-server/runner.js +30 -25
  62. package/dist/runner/codex/app-server/translator.d.ts +0 -5
  63. package/dist/runner/codex/app-server/translator.js +4 -17
  64. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  65. package/dist/runner/common/acp/acp-approval.js +1 -1
  66. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  67. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  68. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  69. package/dist/runner/common/acp/base-translator.js +31 -9
  70. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  71. package/dist/runner/common/acp/protocol-types.js +2 -2
  72. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  73. package/dist/runner/common/connection-based-runner.js +32 -2
  74. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  75. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  76. package/dist/runner/common/spawning-runner.d.ts +1 -1
  77. package/dist/runner/common/spawning-runner.js +1 -1
  78. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  79. package/dist/runner/dsh/catalog-probe.js +10 -0
  80. package/dist/runner/dsh/client.d.ts +1 -3
  81. package/dist/runner/dsh/client.js +2 -4
  82. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  83. package/dist/runner/dsh/exit-cancel.js +2 -2
  84. package/dist/runner/dsh/inflight.d.ts +1 -1
  85. package/dist/runner/dsh/inflight.js +1 -1
  86. package/dist/runner/dsh/runner.d.ts +40 -1
  87. package/dist/runner/dsh/runner.js +272 -199
  88. package/dist/runner/dsh/translator.d.ts +1 -1
  89. package/dist/runner/dsh/translator.js +1 -1
  90. package/dist/runner/index.d.ts +1 -1
  91. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  92. package/dist/runner/kimi/acp/runner.js +1 -2
  93. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  94. package/dist/runner/kimi/acp/translator.js +3 -4
  95. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  96. package/dist/runner/opencode/acp/runner.js +4 -4
  97. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  98. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  99. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  100. package/dist/runner/pi/rpc/runner.js +27 -42
  101. package/dist/runner/types.d.ts +2 -2
  102. package/dist/runner/types.js +1 -1
  103. package/dist/session/claude/sessions.d.ts +45 -0
  104. package/dist/session/claude/sessions.js +97 -71
  105. package/dist/session/codex/rollout-reader.js +4 -4
  106. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  107. package/dist/session/common/throw-session-list-error.js +16 -0
  108. package/dist/session/dsh/sessions.js +11 -5
  109. package/dist/session/kimi/sessions.d.ts +40 -1
  110. package/dist/session/kimi/sessions.js +244 -192
  111. package/dist/session/opencode/sessions.d.ts +153 -1
  112. package/dist/session/opencode/sessions.js +354 -321
  113. package/dist/session/pi/sessions.js +4 -5
  114. package/dist/session/session-store.d.ts +1 -1
  115. package/dist/session/session-store.js +1 -1
  116. package/dist/update/version-check.js +2 -1
  117. package/dist/workspace/index.d.ts +1 -1
  118. package/dist/workspace/index.js +1 -1
  119. package/package.json +1 -1
@@ -57,10 +57,10 @@ function findMergedCommandTail(message) {
57
57
  /** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
58
58
  /**
59
59
  * /order 列表页大小;指令超过此数量时显示分页导航栏。
60
- * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
60
+ * 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
61
61
  * = 61 个 body 元素,触发飞书 ErrCode 11310 "element exceeds the limit"
62
62
  * (21+ 条指令时第 1 页必炸)。改为 15 行:3*15 - 1 + 2 = 46 个元素,留足余量。
63
- * 2026-08-19 别名并卡后每行变 3 个元素(文本 div + 操作 column_set + hr),
63
+ * 别名并卡后每行变 3 个元素(文本 div + 操作 column_set + hr),
64
64
  * 从 15 降为 8:3*8 - 1 + 2 = 25 个元素,仍在余量内,同时保证手机窄屏
65
65
  * 每条指令的操作控件(执行/别名/删除)不被挤压。
66
66
  */
@@ -148,7 +148,7 @@ const APPROVAL_ACTION_SPECS = {
148
148
  }),
149
149
  successToast: '审批已提交',
150
150
  failureToast: (msg) => {
151
- // 审批已过期:给用户明确反馈,不静默不误导(2026-08-12 事故:点了允许无任何反馈)。
151
+ // 审批已过期:给用户明确反馈,不静默不误导(否则点了允许无任何反馈)。
152
152
  const content = /state=expired/.test(msg)
153
153
  ? '⏰ 审批已过期,无法响应'
154
154
  : `审批响应失败:${msg}`;
@@ -296,7 +296,7 @@ const IMMEDIATE_ACTION_CMDS = new Set([
296
296
  'config.save',
297
297
  // 审批响应/权限切换必须即时触达在途 run(同 stop 类控制动作)。若走串行
298
298
  // 队列会排在等待审批的 run 之后形成死锁:run 不结束审批不执行,run 结束
299
- // coordinator 已删响应空转(线上复现:approval.respond 排队卡、审批永不生效)。
299
+ // coordinator 已删响应空转(approval.respond 落进排队卡、审批永不生效)。
300
300
  ...APPROVAL_ACTION_CMDS,
301
301
  ]);
302
302
  /**
@@ -304,7 +304,7 @@ const IMMEDIATE_ACTION_CMDS = new Set([
304
304
  * sessionId / cwd / defaultAgent,必须与入站装配器的 700ms 静默窗口定序:
305
305
  * 控制层(index.ts)在执行它们之前先 flush 该 user/chat 的装配窗口,保证窗口内
306
306
  * 先到的文本消息在本动作之前 commit(绑定动作前的旧状态)。否则窗口内消息会
307
- * 漂移到动作之后的新状态(2026-10-07 事故:输入「重新构建」后立即点「新会话」,
307
+ * 漂移到动作之后的新状态(输入「重新构建」后立即点「新会话」,
308
308
  * 消息落到新会话)。
309
309
  *
310
310
  * 文本命令(/new、/cd、/resume、/config)已由 index.ts 命令分支先 flush,
@@ -321,7 +321,7 @@ export const SESSION_MUTATING_ACTION_CMDS = new Set([
321
321
  * 会产生压缩工作的卡片动作(单源)。这类条目占用 lane 时必须自报身份:
322
322
  * 排队卡写明「正在执行: 🗜 Compact」而不是「前面还有 0 条」,并且期间禁用
323
323
  * 「立即执行」——该按钮走 interruptCurrentRun,会连带中断正在跑的压缩
324
- * (2026-10-09 线上:Compact 占 lane 期间新消息的排队卡自相矛盾,且按钮
324
+ * (Compact 占 lane 期间新消息的排队卡自相矛盾,且按钮
325
325
  * 一点就会杀掉压缩)。同时它们也不允许编辑(预览不是用户文本)。
326
326
  */
327
327
  export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
@@ -400,8 +400,8 @@ export class CommandRouter {
400
400
  *
401
401
  * 草稿(pendingConfig)是全局一份:/config 文本命令重开卡片时沿用同一份草稿
402
402
  * (这是既有语义,不改)。但**视图**是每张卡一份 —— 只刷新被点击的那张,其余
403
- * 卡片就停在旧视图上:用户在旧卡上看到的和点保存实际提交的是两套内容
404
- * (2026-10-08 发现的缺陷)。这里记住所有交互过的 config 卡,任何一次编辑/保存
403
+ * 卡片就停在旧视图上:用户在旧卡上看到的和点保存实际提交的是两套内容。
404
+ * 这里记住所有交互过的 config 卡,任何一次编辑/保存
405
405
  * 后把草稿刷到每张卡,保证「看到的就是会保存的」。
406
406
  */
407
407
  configCardIds = new Set();
@@ -412,7 +412,7 @@ export class CommandRouter {
412
412
  * 条目的身份必须与「一次入队动作」1:1(queuedTasks 的定位、queueCardMessages
413
413
  * 的卡片更新、taskIndex 的取消判据全部按这个 key 做 lookup)。复用卡片 id
414
414
  * 会让同 key 的多条任务互相顶替:位置显示命中最早那条兄弟、第一条开始时删掉
415
- * 索引导致其余兄弟被判「已撤销」静默丢弃(2026-10-08 线上:连点 Compact
415
+ * 索引导致其余兄弟被判「已撤销」静默丢弃(连点 Compact
416
416
  * 三次 → 三张排队卡都显示「第 1 位」、两张永久停在「等待中」,只压缩一次)。
417
417
  *
418
418
  * Date.now() 不够(两次同步调用可落在同一毫秒);计数器与时间无关,恒唯一。
@@ -433,7 +433,7 @@ export class CommandRouter {
433
433
  /** Session reader 注册中心(public:测试注入/读取,替代 as unknown as)。 */
434
434
  sessionReaderRegistry;
435
435
  /**
436
- * 串行化 config.* 卡片回调(2026-07-04)。
436
+ * 串行化 config.* 卡片回调。
437
437
  * CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,**不进串行队列**,
438
438
  * 多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
439
439
  * `updateCardInPlace` → Feishu API 乱序到达,控件卡死在某个状态。
@@ -483,7 +483,7 @@ export class CommandRouter {
483
483
  /**
484
484
  * Route a message: if it starts with /, handle as command; if it starts with !, execute as bash; otherwise forward to Claude.
485
485
  *
486
- * 命令识别前置条件(2026-09-15修复,`inbound-unified-input-design.md` §5.6):
486
+ * 命令识别前置条件(`inbound-unified-input-design.md` §5.6):
487
487
  * 裸前缀判定会被结构占位符命中 —— SDK 把富文本里的图片渲染成 `![image](img_v3_…)`,
488
488
  * 首字符恰好是 `!`,整段用户消息被当 shell 命令执行(`executeBash "[image](img_v3_…"`)。
489
489
  * 因此命令前缀只在「调用方声明纯文本 + 剥离占位符后非空且无占位符 + 首字符是 / 或 !」
@@ -587,40 +587,15 @@ export class CommandRouter {
587
587
  return await this.handleWsSort(value, ctx);
588
588
  case 'ws.remove':
589
589
  return await this.handleWsRemove(value, ctx);
590
- case 'resume.use': {
591
- // resume.use carries an agent field from /resume [agent] list cards
592
- // (both active and completed cards include the agent field).
593
- const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
594
- const entry = this.sessionStore.get(ctx.userId);
595
- const cwd = entry?.cwd;
596
- if (!cwd) {
597
- await this.bridge.sendResult({ text: '请先 /cd 设置工作目录' }, ctx);
598
- return;
599
- }
600
- // 校验前不得写入 sessionId。旧实现先无条件 setSessionId 再调
601
- // cmdResume 校验,过期卡片(session 不在当前 cwd)校验失败时 store 已被
602
- // 污染;value.sessionId 缺失时还会静默清空已有绑定。现在统一走 cmdResume
603
- // 的已验证路径(:2352 校验通过才写入),sessionId 缺失直接报错。
604
- if (!value.sessionId) {
605
- await this.bridge.sendResult({ text: '缺少 sessionId,请重新从 /resume 列表选择会话' }, ctx);
606
- return;
607
- }
608
- await this.bridge.sendResult(this.cmdResume([resolvedAgent, value.sessionId], ctx), ctx);
590
+ case 'resume.use':
591
+ await this.handleResumeUse(value, ctx);
609
592
  return;
610
- }
611
593
  case 'resume.page':
612
594
  return this.handleResumePage(value, ctx);
613
595
  case 'active.page':
614
596
  return this.handleActivePage(value, ctx);
615
597
  case 'new-session':
616
- this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
617
- clearSessionCwd: true,
618
- });
619
- {
620
- const cwd = this.sessionStore.getCwd(ctx.userId) ?? '(未设置)';
621
- const agentName = agentDisplayName(this.config.defaultAgent);
622
- await this.bridge.sendResult({ text: `已创建新 ${agentName} 会话,下一条消息将开始全新对话\n📁 ${cwd}` }, ctx);
623
- }
598
+ await this.handleNewSession(ctx);
624
599
  return;
625
600
  case 'stop':
626
601
  await this.handleCardStop(value, ctx);
@@ -664,43 +639,93 @@ export class CommandRouter {
664
639
  case 'config.set':
665
640
  case 'config.input':
666
641
  case 'config.save':
667
- // 2026-07-04: 串行化所有 config.* 动作。CardKit 2.0 回调经 enqueueImmediate
642
+ // 串行化所有 config.* 动作。CardKit 2.0 回调经 enqueueImmediate
668
643
  // 不进串行队列,多次快速点击下拉/文本框会并发读写 pendingConfig 并同时
669
644
  // 调 updateCardInPlace → Feishu API 乱序到达导致控件卡死。
670
- // 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
645
+ // 返回 enqueueConfigAction 的结果以支持 toast 响应。
671
646
  return this.enqueueConfigAction(value, ctx);
672
647
  case 'compact':
673
- await this.bridge.handleCompactAction(value, ctx);
648
+ await this.handleCompact(value, ctx);
674
649
  return;
675
650
  case 'resume.compact':
676
- await this.bridge.handleResumeCompact(value, ctx);
651
+ await this.handleResumeCompact(value, ctx);
677
652
  return;
678
653
  default: {
679
- // Help card buttons: help.<cmd> → execute /<cmd>
680
654
  if (value.cmd.startsWith('help.')) {
681
- const subCmd = value.cmd.slice(5); // e.g. "status", "ps", "stop"
682
- const result = await this.executeCommand(`/${subCmd}`, ctx);
683
- if (result) {
684
- await this.bridge.sendResult(result, ctx);
685
- }
686
- // /exit、/restart 等命令通过 pendingExit 表达"回复送达后退出"。
687
- // handle() 在 sendResult 后消费它;help.* 按钮点击走 handleCardAction,
688
- // 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
689
- // 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
690
- if (this.pendingExit) {
691
- await this.flushInbound?.();
692
- this.exitHandler();
693
- }
655
+ await this.handleHelpAction(value, ctx);
694
656
  return;
695
657
  }
696
- // Design constraint: miss paths must reply via bridge.sendResult —
697
- // don't silently swallow unknown card actions.
698
- getLogger().warn(`[router] unknown card action: ${value.cmd}`);
699
- await this.bridge.sendResult({ text: `⚠️ 未知的卡片操作: ${value.cmd}` }, ctx);
658
+ await this.handleUnknownCardAction(value, ctx);
700
659
  return;
701
660
  }
702
661
  }
703
662
  }
663
+ /**
664
+ * resume.use: 从 /resume 列表卡切换到所选会话。
665
+ */
666
+ async handleResumeUse(value, ctx) {
667
+ // resume.use carries an agent field from /resume [agent] list cards
668
+ // (both active and completed cards include the agent field).
669
+ const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
670
+ const entry = this.sessionStore.get(ctx.userId);
671
+ const cwd = entry?.cwd;
672
+ if (!cwd) {
673
+ await this.bridge.sendResult({ text: '请先 /cd 设置工作目录' }, ctx);
674
+ return;
675
+ }
676
+ // 校验前不得写入 sessionId。旧实现先无条件 setSessionId 再调
677
+ // cmdResume 校验,过期卡片(session 不在当前 cwd)校验失败时 store 已被
678
+ // 污染;value.sessionId 缺失时还会静默清空已有绑定。现在统一走 cmdResume
679
+ // 的已验证路径(:2352 校验通过才写入),sessionId 缺失直接报错。
680
+ if (!value.sessionId) {
681
+ await this.bridge.sendResult({ text: '缺少 sessionId,请重新从 /resume 列表选择会话' }, ctx);
682
+ return;
683
+ }
684
+ await this.bridge.sendResult(this.cmdResume([resolvedAgent, value.sessionId], ctx), ctx);
685
+ }
686
+ /** new-session: 清空当前会话绑定,下一条消息开始全新对话。 */
687
+ async handleNewSession(ctx) {
688
+ this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
689
+ clearSessionCwd: true,
690
+ });
691
+ const cwd = this.sessionStore.getCwd(ctx.userId) ?? '(未设置)';
692
+ const agentName = agentDisplayName(this.config.defaultAgent);
693
+ await this.bridge.sendResult({ text: `已创建新 ${agentName} 会话,下一条消息将开始全新对话\n📁 ${cwd}` }, ctx);
694
+ }
695
+ /** compact: 对当前会话发起压缩(bridge 收口)。 */
696
+ async handleCompact(value, ctx) {
697
+ await this.bridge.handleCompactAction(value, ctx);
698
+ }
699
+ /** resume.compact: 对 /resume 选中的历史会话发起压缩(bridge 收口)。 */
700
+ async handleResumeCompact(value, ctx) {
701
+ await this.bridge.handleResumeCompact(value, ctx);
702
+ }
703
+ /**
704
+ * Help card buttons: help.<cmd> → execute /<cmd>.
705
+ * /exit、/restart 等命令通过 pendingExit 表达"回复送达后退出"。
706
+ * handle() 在 sendResult 后消费它;help.* 按钮点击走 handleCardAction,
707
+ * 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
708
+ * 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
709
+ */
710
+ async handleHelpAction(value, ctx) {
711
+ const subCmd = value.cmd.slice(5); // e.g. "status", "ps", "stop"
712
+ const result = await this.executeCommand(`/${subCmd}`, ctx);
713
+ if (result) {
714
+ await this.bridge.sendResult(result, ctx);
715
+ }
716
+ if (this.pendingExit) {
717
+ await this.flushInbound?.();
718
+ this.exitHandler();
719
+ }
720
+ }
721
+ /**
722
+ * Design constraint: miss paths must reply via bridge.sendResult —
723
+ * don't silently swallow unknown card actions.
724
+ */
725
+ async handleUnknownCardAction(value, ctx) {
726
+ getLogger().warn(`[router] unknown card action: ${value.cmd}`);
727
+ await this.bridge.sendResult({ text: `⚠️ 未知的卡片操作: ${value.cmd}` }, ctx);
728
+ }
704
729
  /**
705
730
  * Handle approval-related card actions.
706
731
  * Routes to the appropriate bridge method based on value.cmd.
@@ -1330,7 +1355,7 @@ export class CommandRouter {
1330
1355
  cardMessageId: ctx.messageId,
1331
1356
  });
1332
1357
  const stopped = interrupt.stopped;
1333
- // Regression 2026-06-22: card stop button silently no-op'd when the
1358
+ // card stop button silently no-op'd when the
1334
1359
  // run had already exited. Give the user a visible result.
1335
1360
  if (!stopped) {
1336
1361
  await this.bridge.sendResult({ text: '该任务已结束,无需终止' }, ctx);
@@ -1810,8 +1835,6 @@ export class CommandRouter {
1810
1835
  this.configCardIds.add(messageId);
1811
1836
  while (this.configCardIds.size > MAX_CONFIG_CARDS) {
1812
1837
  const oldest = this.configCardIds.values().next().value;
1813
- if (oldest === undefined)
1814
- break;
1815
1838
  this.configCardIds.delete(oldest);
1816
1839
  }
1817
1840
  }
@@ -1858,7 +1881,7 @@ export class CommandRouter {
1858
1881
  }
1859
1882
  }
1860
1883
  /**
1861
- * 串行化 config.* 卡片回调(2026-07-04 修复连击卡死 bug)。
1884
+ * 串行化 config.* 卡片回调(修复连击卡死 bug)。
1862
1885
  *
1863
1886
  * CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,不进 bridge 的
1864
1887
  * 串行队列;多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
@@ -1899,7 +1922,7 @@ export class CommandRouter {
1899
1922
  }
1900
1923
  case 'config.input': {
1901
1924
  // Handle input field: value.key is the field key.
1902
- // 2026-07-04: CardKit 2.0 input 提交(回车/完成键)触发 callback,回传 input_value。
1925
+ // CardKit 2.0 input 提交(回车/完成键)触发 callback,回传 input_value。
1903
1926
  // Fall back to formValue[key]: the SDK currently reports input values
1904
1927
  // via action.formValue for form-wrapped inputs.
1905
1928
  const key = value.key;
@@ -1934,8 +1957,8 @@ export class CommandRouter {
1934
1957
  // defaultAgent 切换的 session 处理)抽到共享方法,与文本直写路径共用。
1935
1958
  const switchResult = this.propagateConfigSave(oldDefaultAgent, updates, ctx);
1936
1959
  this.pendingConfig = null;
1937
- // 原地更新卡片:保存后卡片刷新为已保存状态(2026-07-04)
1938
- // 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1960
+ // 原地更新卡片:保存后卡片刷新为已保存状态。
1961
+ // 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1939
1962
  // 后续切换消息照常发送;真正写盘/传播阶段的失败才走外层 catch 报「保存失败」。
1940
1963
  try {
1941
1964
  // host 可能已变更 → 重新预取 DSH 目录(内部按 host 缓存,未变不重复拉)
@@ -1945,7 +1968,7 @@ export class CommandRouter {
1945
1968
  catch (refreshErr) {
1946
1969
  getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);
1947
1970
  }
1948
- // 2026-08-13: agent 切换时发送 Resume 卡片(替代纯文本通知),
1971
+ // agent 切换时发送 Resume 卡片(替代纯文本通知),
1949
1972
  // 让用户直观看到新 agent 的会话状态和历史。
1950
1973
  // 发送失败(resolve false)时兜底回退 toast 即时反馈。
1951
1974
  if (switchResult.notice) {
@@ -1996,7 +2019,7 @@ export class CommandRouter {
1996
2019
  // idle.watchdogMinutes 改动需同步到 bridge 的 idleTimeoutMs
1997
2020
  this.idleTimeoutMs = this.config.idle.watchdogMinutes * 60_000;
1998
2021
  this.bridge.setIdleTimeout(this.idleTimeoutMs);
1999
- // 2026-07-13: 检测是否有任何 agent 配置变更或 defaultAgent 切换
2022
+ // 检测是否有任何 agent 配置变更或 defaultAgent 切换
2000
2023
  const hasDefaultAgentChange = oldDefaultAgent !== this.config.defaultAgent;
2001
2024
  const changedKeys = Object.keys(updates);
2002
2025
  const hasAgentConfigChange = changedKeys.includes('defaultAgent') ||
@@ -2070,7 +2093,7 @@ export class CommandRouter {
2070
2093
  };
2071
2094
  parkDshSessionBoundary(changedKeys.includes('agents.dsh.agentPreset'), 'preset');
2072
2095
  parkDshSessionBoundary(changedKeys.includes('agents.dsh.host'), 'host');
2073
- // 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
2096
+ // 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
2074
2097
  const currentAgent = this.config.defaultAgent;
2075
2098
  if (touchesAgentsSubtree(changedKeys, currentAgent)) {
2076
2099
  this.config = syncAgentChoices(this.config, currentAgent);
@@ -2089,7 +2112,7 @@ export class CommandRouter {
2089
2112
  if (hasApprovalConfigChange) {
2090
2113
  this.bridge.syncActiveApprovalModes();
2091
2114
  }
2092
- // 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
2115
+ // 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
2093
2116
  if (hasDefaultAgentChange || hasAgentConfigChange) {
2094
2117
  this.bridge.clearRunners();
2095
2118
  }
@@ -2161,8 +2184,8 @@ export class CommandRouter {
2161
2184
  // --- Command implementations ---
2162
2185
  cmdHelp() {
2163
2186
  // 按钮组(可直接点击触发;按钮 label 只保留子命令,不含参数)
2164
- // 2026-07-04: /ws 子命令 (save|use|remove) 移到右侧文本说明,按钮只显示 /ws
2165
- // 2026-07-04: 按钮 label 按长度升序排列(短在上,长在下),视觉更整齐
2187
+ // /ws 子命令 (save|use|remove) 移到右侧文本说明,按钮只显示 /ws
2188
+ // 按钮 label 按长度升序排列(短在上,长在下),视觉更整齐
2166
2189
  const buttonCommands = [
2167
2190
  { cmd: 'help', label: '/help /h', desc: '显示此帮助' },
2168
2191
  { cmd: 'ps', label: '/ps', desc: '查看是否有 agent 进程在跑' },
@@ -2203,11 +2226,11 @@ export class CommandRouter {
2203
2226
  ];
2204
2227
  const bodyElements = [];
2205
2228
  // 按钮组 — CardKit 2.0: column_set + column + button with behaviors
2206
- // 2026-07-04 对齐修复(方案 A):两列都用 width:'weighted' + 固定 weight。
2229
+ // 对齐修复(方案 A):两列都用 width:'weighted' + 固定 weight。
2207
2230
  // 根因:每行独立 column_set + width:'auto' → 列宽随按钮/文本内容变化 →
2208
2231
  // 跨行不对齐(按钮列右边界参差)。改用 weighted 后,每行 column_set
2209
2232
  // 总宽相同(同级 body.elements),weight 比例一致,列宽跨行恒等。
2210
- // 2026-07-05 手机排版修复:weight 1:3 → 2:3。1:3 时按钮列只占 25%,
2233
+ // 手机排版修复:weight 1:3 → 2:3。1:3 时按钮列只占 25%,
2211
2234
  // 手机窄屏(~330px 可用)下约 82px,容不下 `/config /c`(10 字符 ≈ 100px),
2212
2235
  // 按钮文字被截断成 "..."。改 2:3 后按钮列占 40% ≈ 132px,足够显示
2213
2236
  // 最长的 /config /c,文本列仍占 60% 足以放下描述。
@@ -2460,8 +2483,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2460
2483
  }
2461
2484
  cmdNew(ctx) {
2462
2485
  // 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
2463
- // 设置工作目录",但用户的 cwd 还在。2026-06-21 复盘:用户
2464
- // /new → /resume 后被误导以为需要重新 /cd。
2486
+ // 设置工作目录",但用户的 cwd 还在,用户 /new → /resume 后会被误导
2487
+ // 以为需要重新 /cd。
2465
2488
  const agentName = agentDisplayName(this.config.defaultAgent);
2466
2489
  this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
2467
2490
  clearSessionCwd: true,
@@ -2546,38 +2569,66 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2546
2569
  }
2547
2570
  /** Build card for auto-resumed session with new session button */
2548
2571
  buildAutoResumeCard(cwd, session, _ctx) {
2549
- const agentName = agentDisplayName(this.config.defaultAgent);
2550
- const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(session.sessionId, cwd, { maxEvents: AUTO_RESUME_MAX_EVENTS });
2572
+ const agentKind = this.config.defaultAgent;
2573
+ return this.buildResumeLikeCard({
2574
+ cwd,
2575
+ sessionId: session.sessionId,
2576
+ agentKind,
2577
+ // 仅 auto-resume 入口带 Compact 按钮(runner 支持压缩时);
2578
+ // config-switch 卡不带。
2579
+ compact: this.canCompactSession(agentKind, cwd)
2580
+ ? { sessionId: session.sessionId, agentKind }
2581
+ : undefined,
2582
+ titleBase: '自动恢复会话',
2583
+ idleIcon: '🔁',
2584
+ resumedLine: `已恢复最近会话: **${session.sessionId}**`,
2585
+ });
2586
+ }
2587
+ /**
2588
+ * Shared body of the two "resume-like" cards(cwd 切换后的 auto-resume 与
2589
+ * config.save 换 agent):两者都是同一序列 readSessionDisplayState →
2590
+ * buildResumeActionButtons → buildSessionHistoryCard,差异仅 4 处——
2591
+ * Compact 按钮(仅 auto-resume,canCompactSession 门控)、标题文案、
2592
+ * headerText 的「已恢复…会话」行与可选尾部行、空会话占位——全部参数化,
2593
+ * 共享序列只留一份(Clean Code DRY)。
2594
+ */
2595
+ buildResumeLikeCard(opts) {
2596
+ const agentName = agentDisplayName(opts.agentKind);
2597
+ const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(opts.sessionId, opts.cwd, {
2598
+ // 默认 agent 时 readSessionDisplayState 内部仍走 this.sessionReader
2599
+ //(agentKind === defaultAgent 不触发 registry 查找),与旧的不传等价。
2600
+ agentKind: opts.agentKind,
2601
+ maxEvents: AUTO_RESUME_MAX_EVENTS,
2602
+ });
2551
2603
  const actions = this.buildResumeActionButtons({
2552
2604
  isActive,
2553
2605
  activeRunRunId,
2554
- cwd,
2555
- compact: this.canCompactSession(this.config.defaultAgent, cwd)
2556
- ? { sessionId: session.sessionId, agentKind: this.config.defaultAgent }
2557
- : undefined,
2606
+ cwd: opts.cwd,
2607
+ compact: opts.compact,
2558
2608
  newSession: true,
2559
2609
  });
2560
2610
  const card = buildSessionHistoryCard({
2561
- sessionId: session.sessionId,
2562
- cwd,
2611
+ sessionId: opts.sessionId,
2612
+ cwd: opts.cwd,
2563
2613
  displayTitle,
2564
2614
  aiTitle,
2565
2615
  recap,
2566
2616
  events: content,
2567
2617
  usage,
2568
2618
  }, {
2569
- agentKind: this.config.defaultAgent,
2570
- headerText: `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`,
2571
- title: `${isActive ? '⏳ 自动恢复会话(完成中)' : '🔁 自动恢复会话'} · ${agentName}`,
2619
+ agentKind: opts.agentKind,
2620
+ headerText: `📂 \`${opts.cwd}\`\n${opts.resumedLine}${opts.headerTail ? `\n${opts.headerTail}` : ''}`,
2621
+ title: `${isActive ? `⏳ ${opts.titleBase}(完成中)` : `${opts.idleIcon} ${opts.titleBase}`} · ${agentName}`,
2572
2622
  usageResult: 'success',
2623
+ ...(opts.emptyPlaceholder !== undefined ? { emptyPlaceholder: opts.emptyPlaceholder } : {}),
2573
2624
  actions,
2574
2625
  });
2575
2626
  return { card };
2576
2627
  }
2577
2628
  /**
2578
2629
  * Build card for config.save agent switch.
2579
- * When the new agent has a restored session, renders a Resume card (mirrors
2580
- * buildAutoResumeCard) with session history + stop/new buttons.
2630
+ * When the new agent has a restored session, renders a Resume card (shared
2631
+ * body via buildResumeLikeCard) with session history + stop/new buttons.
2581
2632
  * When the session was cleared, renders a compact notice card instead.
2582
2633
  */
2583
2634
  buildConfigSwitchCard(switchResult, ctx) {
@@ -2601,62 +2652,25 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2601
2652
  },
2602
2653
  };
2603
2654
  }
2604
- // Has session → Resume card (mirrors buildAutoResumeCard)
2605
- // Guard: if the session reader for the new agent is not available (e.g. agent
2606
- // not registered), fall back to compact notice card to avoid NPE.
2607
- const readerForNewAgent = newAgent === this.config.defaultAgent
2608
- ? this.sessionReader
2609
- : this.sessionReaderRegistry.get(newAgent);
2610
- if (!readerForNewAgent) {
2611
- // Fallback: compact notice card with the notice text
2612
- const elements = [markdownDiv(switchResult.notice)];
2613
- elements.push({
2614
- tag: 'column_set',
2615
- columns: [
2616
- {
2617
- tag: 'column',
2618
- width: 'auto',
2619
- elements: [newSessionButton()],
2620
- },
2621
- ],
2622
- });
2623
- return {
2624
- card: {
2625
- schema: '2.0',
2626
- config: { wide_screen_mode: true },
2627
- header: { title: { tag: 'plain_text', content: `🔀 切换到 ${agentName}` } },
2628
- body: { elements },
2629
- },
2630
- };
2631
- }
2632
- const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionId, cwd, {
2633
- agentKind: newAgent,
2634
- maxEvents: AUTO_RESUME_MAX_EVENTS,
2635
- });
2636
- // Action buttons: stop (active) + new session
2637
- const actions = this.buildResumeActionButtons({
2638
- isActive,
2639
- activeRunRunId,
2655
+ // Has session → Resume card (shared body with buildAutoResumeCard)
2656
+ // SessionReaderRegistry.get() throws when the agent is not registered —
2657
+ // a null-reader fallback branch here is unreachable either way.
2658
+ return this.buildResumeLikeCard({
2640
2659
  cwd,
2641
- newSession: true,
2642
- });
2643
- const card = buildSessionHistoryCard({
2644
2660
  sessionId,
2645
- cwd,
2646
- displayTitle,
2647
- aiTitle,
2648
- recap,
2649
- events: content,
2650
- usage,
2651
- }, {
2652
2661
  agentKind: newAgent,
2653
- headerText: `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`,
2654
- title: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
2655
- usageResult: 'success',
2662
+ titleBase: '切换 Agent',
2663
+ idleIcon: '🔀',
2664
+ // 切换语义(已使用所选 / 将继续之前的 / 已清空)必须随卡可见:
2665
+ // 2026-08-13 换 Resume 卡时这条信息一度只在「无 reader」回退分支
2666
+ // 渲染,生产路径(registry 恒有 reader)上不可见——卡上只剩
2667
+ // sessionId,用户无法区分显式选择、停车恢复与清空。notice 含
2668
+ // sessionId,与「已恢复会话」行并存(agent-switch-entry-points 与
2669
+ // arrival-baseline 两族 anchor 分别钉住两行)。
2670
+ resumedLine: `已恢复会话: **${sessionId}**`,
2671
+ headerTail: switchResult.notice,
2656
2672
  emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2657
- actions,
2658
2673
  });
2659
- return { card };
2660
2674
  }
2661
2675
  /**
2662
2676
  * Whether a session of the given agent kind can be compacted from a resume
@@ -2701,7 +2715,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2701
2715
  * @param offset 分页起点(条目数)
2702
2716
  * @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
2703
2717
  * 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
2704
- * 丢回 工作目录(用户明确反馈过)。
2718
+ * 丢回 工作目录。
2705
2719
  * @param q 关键词筛选:只匹配**当前层**条目名(不递归),大小写不敏感。
2706
2720
  * 省略/空 = 无筛选。
2707
2721
  */
@@ -2791,7 +2805,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2791
2805
  }
2792
2806
  cmdActive(_args, _ctx, offset = 0) {
2793
2807
  // List all active runs from lark-remote memory (not from file system scan)
2794
- // New semantics (2026-07-20): only shows runs started by THIS lark-remote process
2808
+ // (only shows runs started by THIS lark-remote process)
2795
2809
  const activeRuns = this.bridge.getActiveRuns();
2796
2810
  const activeBashRuns = this.bridge.getActiveBashRuns();
2797
2811
  if (activeRuns.length === 0 && activeBashRuns.length === 0) {
@@ -2815,54 +2829,31 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2815
2829
  const elements = [];
2816
2830
  let remaining = pageSize;
2817
2831
  let skipped = safeOffset;
2818
- // Agent runs section
2819
- if (activeRuns.length > 0 && skipped < activeRuns.length && remaining > 0) {
2820
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
2821
- const start = skipped;
2822
- const end = Math.min(start + remaining, activeRuns.length);
2823
- for (let i = start; i < end; i++) {
2824
- const run = activeRuns[i];
2825
- const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2826
- elements.push({
2827
- tag: 'div',
2828
- text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
2829
- });
2830
- elements.push({
2831
- tag: 'div',
2832
- text: {
2833
- tag: 'lark_md',
2834
- content: `> session: ${run.sessionId.slice(0, 12)}... \n> 状态: ${statusLabel}`,
2835
- },
2836
- });
2837
- elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
2838
- elements.push({ tag: 'hr' });
2839
- }
2840
- remaining -= end - start;
2841
- skipped = 0;
2842
- }
2843
- else {
2844
- skipped -= activeRuns.length;
2845
- }
2846
- // Bash runs section
2847
- if (activeBashRuns.length > 0 && skipped < activeBashRuns.length && remaining > 0) {
2848
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
2849
- const start = skipped;
2850
- const end = Math.min(start + remaining, activeBashRuns.length);
2851
- for (let i = start; i < end; i++) {
2852
- const run = activeBashRuns[i];
2853
- const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2854
- elements.push({
2855
- tag: 'div',
2856
- text: { tag: 'lark_md', content: `**\`${run.command}\`**` },
2857
- });
2858
- elements.push({
2859
- tag: 'div',
2860
- text: { tag: 'lark_md', content: `> 目录: ${run.cwd} \n> 状态: ${statusLabel}` },
2861
- });
2862
- elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
2863
- elements.push({ tag: 'hr' });
2864
- }
2865
- }
2832
+ // Agent runs section(跨段 offset 分配:agent 段吃满 remaining 才轮到 bash 段)
2833
+ ({ remaining, skipped } = this.appendActiveCardSection(elements, activeRuns, '## 🤖 Agent 任务', (run, statusLabel) => [
2834
+ {
2835
+ tag: 'div',
2836
+ text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
2837
+ },
2838
+ {
2839
+ tag: 'div',
2840
+ text: {
2841
+ tag: 'lark_md',
2842
+ content: `> session: ${run.sessionId.slice(0, 12)}... \n> 状态: ${statusLabel}`,
2843
+ },
2844
+ },
2845
+ ], remaining, skipped));
2846
+ // Bash runs section(末段:不再使用返回的记账值)
2847
+ this.appendActiveCardSection(elements, activeBashRuns, '## 💻 Bash 命令', (run, statusLabel) => [
2848
+ {
2849
+ tag: 'div',
2850
+ text: { tag: 'lark_md', content: `**\`${run.command}\`**` },
2851
+ },
2852
+ {
2853
+ tag: 'div',
2854
+ text: { tag: 'lark_md', content: `> 目录: ${run.cwd} \n> 状态: ${statusLabel}` },
2855
+ },
2856
+ ], remaining, skipped);
2866
2857
  // Pagination bar(复用 paginationBar:safeOffset 已对齐页边界,
2867
2858
  // offset±pageSize 与原先 currentPage±1 的页码换算等价)
2868
2859
  if (totalPages > 1) {
@@ -2887,6 +2878,34 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2887
2878
  };
2888
2879
  return { card };
2889
2880
  }
2881
+ /**
2882
+ * Render one section of the /active(/ps)card — the agent-runs and
2883
+ * bash-runs sections are the same loop (title line → per-entry rows →
2884
+ * stop button → trailing hr) and differ only in the title and the two
2885
+ * per-entry content rows, which the caller supplies via `renderRows`.
2886
+ *
2887
+ * Cross-section offset accounting (caller threads `remaining`/`skipped`
2888
+ * through the returned value): entries rendered decrement `remaining` and
2889
+ * consume the offset (`skipped` → 0, so the next section renders from its
2890
+ * own head); a section the offset/page has moved past deducts its full size
2891
+ * from `skipped` instead, so agent runs eat the offset before bash runs do.
2892
+ */
2893
+ appendActiveCardSection(elements, runs, title, renderRows, remaining, skipped) {
2894
+ if (runs.length > 0 && skipped < runs.length && remaining > 0) {
2895
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: title } });
2896
+ const start = skipped;
2897
+ const end = Math.min(start + remaining, runs.length);
2898
+ for (let i = start; i < end; i++) {
2899
+ const run = runs[i];
2900
+ const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2901
+ elements.push(...renderRows(run, statusLabel));
2902
+ elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
2903
+ elements.push({ tag: 'hr' });
2904
+ }
2905
+ return { remaining: remaining - (end - start), skipped: 0 };
2906
+ }
2907
+ return { remaining, skipped: skipped - runs.length };
2908
+ }
2890
2909
  async cmdConfig(args, ctx) {
2891
2910
  // Ensure probe cache is populated before building the card.
2892
2911
  await probeAllAgents();
@@ -2913,21 +2932,21 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2913
2932
  // defaultAgent 不再漏判。
2914
2933
  const switchResult = this.propagateConfigSave(oldDefaultAgent, { [mapAgentKey(key)]: value }, ctx);
2915
2934
  this.pendingConfig = null; // 命令式写盘后清空暂存,避免不一致
2916
- // 2026-08-13: agent 切换时额外发送 Resume 卡片
2935
+ // agent 切换时额外发送 Resume 卡片
2917
2936
  if (switchResult.notice) {
2918
2937
  const switchCard = this.buildConfigSwitchCard(switchResult, ctx);
2919
2938
  // 文本路径返回 config card,Resume card 通过 bridge 异步发送
2920
2939
  void this.bridge.sendResult(switchCard, ctx);
2921
2940
  }
2922
2941
  const card = this.buildConfigCard();
2923
- if (discarded > 0 && card.card && typeof card.card === 'object') {
2942
+ if (discarded > 0) {
2943
+ // buildConfigCardFromTabs 恒返回 body.elements(至少含保存按钮),
2944
+ // 这里直接收窄插入提示条——同模块自己刚构建的值无需逐层探测。
2924
2945
  const body = card.card.body;
2925
- if (body && Array.isArray(body.elements)) {
2926
- body.elements.unshift({
2927
- tag: 'div',
2928
- text: { tag: 'lark_md', content: `⚠️ 已丢弃 ${discarded} 项未保存修改` },
2929
- });
2930
- }
2946
+ body.elements.unshift({
2947
+ tag: 'div',
2948
+ text: { tag: 'lark_md', content: `⚠️ 已丢弃 ${discarded} 项未保存修改` },
2949
+ });
2931
2950
  }
2932
2951
  return card;
2933
2952
  }