lark-remote 0.5.18 → 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 (123) hide show
  1. package/CHANGELOG.md +32 -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 +120 -119
  11. package/dist/bridge/index.js +257 -695
  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/bash-card-session.js +2 -0
  19. package/dist/card/card-budget.js +1 -1
  20. package/dist/card/card-helpers.d.ts +3 -3
  21. package/dist/card/card-helpers.js +6 -6
  22. package/dist/card/card-session.d.ts +17 -0
  23. package/dist/card/card-session.js +35 -0
  24. package/dist/card/final-answer-file.js +1 -1
  25. package/dist/card/run-card-session.js +2 -0
  26. package/dist/card/run-renderer.d.ts +3 -3
  27. package/dist/card/run-renderer.js +77 -102
  28. package/dist/card/run-state.d.ts +35 -8
  29. package/dist/card/run-state.js +117 -90
  30. package/dist/card/tool-render.d.ts +1 -1
  31. package/dist/card/tool-render.js +1 -1
  32. package/dist/card/usage-format.d.ts +12 -0
  33. package/dist/card/usage-format.js +8 -3
  34. package/dist/cli.js +15 -3
  35. package/dist/clone.js +2 -2
  36. package/dist/config/codex-config.js +130 -106
  37. package/dist/config/index.d.ts +16 -2
  38. package/dist/config/index.js +23 -8
  39. package/dist/connector/index.js +6 -6
  40. package/dist/error-classification.js +3 -2
  41. package/dist/inbound/placeholder.d.ts +1 -1
  42. package/dist/inbound/placeholder.js +1 -1
  43. package/dist/index.js +203 -133
  44. package/dist/logger/index.js +6 -6
  45. package/dist/message-handlers.d.ts +0 -5
  46. package/dist/message-handlers.js +4 -2
  47. package/dist/model-catalog.d.ts +2 -2
  48. package/dist/model-catalog.js +3 -8
  49. package/dist/platform/identity.js +1 -1
  50. package/dist/platform/path.d.ts +1 -1
  51. package/dist/platform/path.js +1 -1
  52. package/dist/platform/sleep-blocker.js +1 -1
  53. package/dist/platform/terminator-win32.js +2 -1
  54. package/dist/router/commands/resume.js +2 -20
  55. package/dist/router/config/dsh.d.ts +4 -8
  56. package/dist/router/config/dsh.js +3 -10
  57. package/dist/router/config/kimi.js +1 -1
  58. package/dist/router/config/types.d.ts +1 -1
  59. package/dist/router/config-diff.js +1 -1
  60. package/dist/router/index.d.ts +56 -11
  61. package/dist/router/index.js +222 -202
  62. package/dist/runner/claude/session.d.ts +1 -1
  63. package/dist/runner/claude/session.js +6 -4
  64. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  65. package/dist/runner/codex/app-server/runner.js +30 -25
  66. package/dist/runner/codex/app-server/translator.d.ts +0 -5
  67. package/dist/runner/codex/app-server/translator.js +4 -17
  68. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  69. package/dist/runner/common/acp/acp-approval.js +1 -1
  70. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  71. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  72. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  73. package/dist/runner/common/acp/base-translator.js +31 -9
  74. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  75. package/dist/runner/common/acp/protocol-types.js +2 -2
  76. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  77. package/dist/runner/common/connection-based-runner.js +32 -2
  78. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  79. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  80. package/dist/runner/common/spawning-runner.d.ts +1 -1
  81. package/dist/runner/common/spawning-runner.js +1 -1
  82. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  83. package/dist/runner/dsh/catalog-probe.js +10 -0
  84. package/dist/runner/dsh/client.d.ts +13 -8
  85. package/dist/runner/dsh/client.js +23 -15
  86. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  87. package/dist/runner/dsh/exit-cancel.js +2 -2
  88. package/dist/runner/dsh/inflight.d.ts +1 -1
  89. package/dist/runner/dsh/inflight.js +1 -1
  90. package/dist/runner/dsh/runner.d.ts +48 -2
  91. package/dist/runner/dsh/runner.js +288 -199
  92. package/dist/runner/dsh/translator.d.ts +1 -1
  93. package/dist/runner/dsh/translator.js +1 -1
  94. package/dist/runner/index.d.ts +1 -1
  95. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  96. package/dist/runner/kimi/acp/runner.js +1 -2
  97. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  98. package/dist/runner/kimi/acp/translator.js +3 -4
  99. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  100. package/dist/runner/opencode/acp/runner.js +4 -4
  101. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  102. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  103. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  104. package/dist/runner/pi/rpc/runner.js +27 -42
  105. package/dist/runner/types.d.ts +25 -8
  106. package/dist/runner/types.js +1 -1
  107. package/dist/session/claude/sessions.d.ts +45 -0
  108. package/dist/session/claude/sessions.js +97 -71
  109. package/dist/session/codex/rollout-reader.js +4 -4
  110. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  111. package/dist/session/common/throw-session-list-error.js +16 -0
  112. package/dist/session/dsh/sessions.js +11 -5
  113. package/dist/session/kimi/sessions.d.ts +40 -1
  114. package/dist/session/kimi/sessions.js +244 -192
  115. package/dist/session/opencode/sessions.d.ts +153 -1
  116. package/dist/session/opencode/sessions.js +354 -321
  117. package/dist/session/pi/sessions.js +4 -5
  118. package/dist/session/session-store.d.ts +1 -1
  119. package/dist/session/session-store.js +1 -1
  120. package/dist/update/version-check.js +2 -1
  121. package/dist/workspace/index.d.ts +1 -1
  122. package/dist/workspace/index.js +1 -1
  123. 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.
@@ -1319,17 +1344,18 @@ export class CommandRouter {
1319
1344
  * Handle stop card action: interrupt current run.
1320
1345
  */
1321
1346
  async handleCardStop(value, ctx) {
1322
- if (!value.runId || !ctx.userId || !ctx.chatId) {
1347
+ if (!ctx.userId || !ctx.chatId || (!value.runId && !ctx.messageId)) {
1323
1348
  await this.bridge.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, ctx);
1324
1349
  return;
1325
1350
  }
1326
1351
  const interrupt = await this.bridge.interruptCurrentRunDetailed({
1327
1352
  userId: ctx.userId,
1328
1353
  chatId: ctx.chatId,
1329
- runId: value.runId,
1354
+ ...(value.runId ? { runId: value.runId } : {}),
1355
+ cardMessageId: ctx.messageId,
1330
1356
  });
1331
1357
  const stopped = interrupt.stopped;
1332
- // Regression 2026-06-22: card stop button silently no-op'd when the
1358
+ // card stop button silently no-op'd when the
1333
1359
  // run had already exited. Give the user a visible result.
1334
1360
  if (!stopped) {
1335
1361
  await this.bridge.sendResult({ text: '该任务已结束,无需终止' }, ctx);
@@ -1809,8 +1835,6 @@ export class CommandRouter {
1809
1835
  this.configCardIds.add(messageId);
1810
1836
  while (this.configCardIds.size > MAX_CONFIG_CARDS) {
1811
1837
  const oldest = this.configCardIds.values().next().value;
1812
- if (oldest === undefined)
1813
- break;
1814
1838
  this.configCardIds.delete(oldest);
1815
1839
  }
1816
1840
  }
@@ -1857,7 +1881,7 @@ export class CommandRouter {
1857
1881
  }
1858
1882
  }
1859
1883
  /**
1860
- * 串行化 config.* 卡片回调(2026-07-04 修复连击卡死 bug)。
1884
+ * 串行化 config.* 卡片回调(修复连击卡死 bug)。
1861
1885
  *
1862
1886
  * CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,不进 bridge 的
1863
1887
  * 串行队列;多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
@@ -1898,7 +1922,7 @@ export class CommandRouter {
1898
1922
  }
1899
1923
  case 'config.input': {
1900
1924
  // Handle input field: value.key is the field key.
1901
- // 2026-07-04: CardKit 2.0 input 提交(回车/完成键)触发 callback,回传 input_value。
1925
+ // CardKit 2.0 input 提交(回车/完成键)触发 callback,回传 input_value。
1902
1926
  // Fall back to formValue[key]: the SDK currently reports input values
1903
1927
  // via action.formValue for form-wrapped inputs.
1904
1928
  const key = value.key;
@@ -1933,8 +1957,8 @@ export class CommandRouter {
1933
1957
  // defaultAgent 切换的 session 处理)抽到共享方法,与文本直写路径共用。
1934
1958
  const switchResult = this.propagateConfigSave(oldDefaultAgent, updates, ctx);
1935
1959
  this.pendingConfig = null;
1936
- // 原地更新卡片:保存后卡片刷新为已保存状态(2026-07-04)
1937
- // 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1960
+ // 原地更新卡片:保存后卡片刷新为已保存状态。
1961
+ // 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1938
1962
  // 后续切换消息照常发送;真正写盘/传播阶段的失败才走外层 catch 报「保存失败」。
1939
1963
  try {
1940
1964
  // host 可能已变更 → 重新预取 DSH 目录(内部按 host 缓存,未变不重复拉)
@@ -1944,7 +1968,7 @@ export class CommandRouter {
1944
1968
  catch (refreshErr) {
1945
1969
  getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);
1946
1970
  }
1947
- // 2026-08-13: agent 切换时发送 Resume 卡片(替代纯文本通知),
1971
+ // agent 切换时发送 Resume 卡片(替代纯文本通知),
1948
1972
  // 让用户直观看到新 agent 的会话状态和历史。
1949
1973
  // 发送失败(resolve false)时兜底回退 toast 即时反馈。
1950
1974
  if (switchResult.notice) {
@@ -1995,7 +2019,7 @@ export class CommandRouter {
1995
2019
  // idle.watchdogMinutes 改动需同步到 bridge 的 idleTimeoutMs
1996
2020
  this.idleTimeoutMs = this.config.idle.watchdogMinutes * 60_000;
1997
2021
  this.bridge.setIdleTimeout(this.idleTimeoutMs);
1998
- // 2026-07-13: 检测是否有任何 agent 配置变更或 defaultAgent 切换
2022
+ // 检测是否有任何 agent 配置变更或 defaultAgent 切换
1999
2023
  const hasDefaultAgentChange = oldDefaultAgent !== this.config.defaultAgent;
2000
2024
  const changedKeys = Object.keys(updates);
2001
2025
  const hasAgentConfigChange = changedKeys.includes('defaultAgent') ||
@@ -2069,7 +2093,7 @@ export class CommandRouter {
2069
2093
  };
2070
2094
  parkDshSessionBoundary(changedKeys.includes('agents.dsh.agentPreset'), 'preset');
2071
2095
  parkDshSessionBoundary(changedKeys.includes('agents.dsh.host'), 'host');
2072
- // 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
2096
+ // 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
2073
2097
  const currentAgent = this.config.defaultAgent;
2074
2098
  if (touchesAgentsSubtree(changedKeys, currentAgent)) {
2075
2099
  this.config = syncAgentChoices(this.config, currentAgent);
@@ -2088,7 +2112,7 @@ export class CommandRouter {
2088
2112
  if (hasApprovalConfigChange) {
2089
2113
  this.bridge.syncActiveApprovalModes();
2090
2114
  }
2091
- // 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
2115
+ // 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
2092
2116
  if (hasDefaultAgentChange || hasAgentConfigChange) {
2093
2117
  this.bridge.clearRunners();
2094
2118
  }
@@ -2160,8 +2184,8 @@ export class CommandRouter {
2160
2184
  // --- Command implementations ---
2161
2185
  cmdHelp() {
2162
2186
  // 按钮组(可直接点击触发;按钮 label 只保留子命令,不含参数)
2163
- // 2026-07-04: /ws 子命令 (save|use|remove) 移到右侧文本说明,按钮只显示 /ws
2164
- // 2026-07-04: 按钮 label 按长度升序排列(短在上,长在下),视觉更整齐
2187
+ // /ws 子命令 (save|use|remove) 移到右侧文本说明,按钮只显示 /ws
2188
+ // 按钮 label 按长度升序排列(短在上,长在下),视觉更整齐
2165
2189
  const buttonCommands = [
2166
2190
  { cmd: 'help', label: '/help /h', desc: '显示此帮助' },
2167
2191
  { cmd: 'ps', label: '/ps', desc: '查看是否有 agent 进程在跑' },
@@ -2202,11 +2226,11 @@ export class CommandRouter {
2202
2226
  ];
2203
2227
  const bodyElements = [];
2204
2228
  // 按钮组 — CardKit 2.0: column_set + column + button with behaviors
2205
- // 2026-07-04 对齐修复(方案 A):两列都用 width:'weighted' + 固定 weight。
2229
+ // 对齐修复(方案 A):两列都用 width:'weighted' + 固定 weight。
2206
2230
  // 根因:每行独立 column_set + width:'auto' → 列宽随按钮/文本内容变化 →
2207
2231
  // 跨行不对齐(按钮列右边界参差)。改用 weighted 后,每行 column_set
2208
2232
  // 总宽相同(同级 body.elements),weight 比例一致,列宽跨行恒等。
2209
- // 2026-07-05 手机排版修复:weight 1:3 → 2:3。1:3 时按钮列只占 25%,
2233
+ // 手机排版修复:weight 1:3 → 2:3。1:3 时按钮列只占 25%,
2210
2234
  // 手机窄屏(~330px 可用)下约 82px,容不下 `/config /c`(10 字符 ≈ 100px),
2211
2235
  // 按钮文字被截断成 "..."。改 2:3 后按钮列占 40% ≈ 132px,足够显示
2212
2236
  // 最长的 /config /c,文本列仍占 60% 足以放下描述。
@@ -2459,8 +2483,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2459
2483
  }
2460
2484
  cmdNew(ctx) {
2461
2485
  // 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
2462
- // 设置工作目录",但用户的 cwd 还在。2026-06-21 复盘:用户
2463
- // /new → /resume 后被误导以为需要重新 /cd。
2486
+ // 设置工作目录",但用户的 cwd 还在,用户 /new → /resume 后会被误导
2487
+ // 以为需要重新 /cd。
2464
2488
  const agentName = agentDisplayName(this.config.defaultAgent);
2465
2489
  this.sessionStore.clearSessionId(ctx.userId, this.config.defaultAgent, {
2466
2490
  clearSessionCwd: true,
@@ -2545,38 +2569,66 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2545
2569
  }
2546
2570
  /** Build card for auto-resumed session with new session button */
2547
2571
  buildAutoResumeCard(cwd, session, _ctx) {
2548
- const agentName = agentDisplayName(this.config.defaultAgent);
2549
- 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
+ });
2550
2603
  const actions = this.buildResumeActionButtons({
2551
2604
  isActive,
2552
2605
  activeRunRunId,
2553
- cwd,
2554
- compact: this.canCompactSession(this.config.defaultAgent, cwd)
2555
- ? { sessionId: session.sessionId, agentKind: this.config.defaultAgent }
2556
- : undefined,
2606
+ cwd: opts.cwd,
2607
+ compact: opts.compact,
2557
2608
  newSession: true,
2558
2609
  });
2559
2610
  const card = buildSessionHistoryCard({
2560
- sessionId: session.sessionId,
2561
- cwd,
2611
+ sessionId: opts.sessionId,
2612
+ cwd: opts.cwd,
2562
2613
  displayTitle,
2563
2614
  aiTitle,
2564
2615
  recap,
2565
2616
  events: content,
2566
2617
  usage,
2567
2618
  }, {
2568
- agentKind: this.config.defaultAgent,
2569
- headerText: `📂 \`${cwd}\`\n已恢复最近会话: **${session.sessionId}**`,
2570
- 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}`,
2571
2622
  usageResult: 'success',
2623
+ ...(opts.emptyPlaceholder !== undefined ? { emptyPlaceholder: opts.emptyPlaceholder } : {}),
2572
2624
  actions,
2573
2625
  });
2574
2626
  return { card };
2575
2627
  }
2576
2628
  /**
2577
2629
  * Build card for config.save agent switch.
2578
- * When the new agent has a restored session, renders a Resume card (mirrors
2579
- * 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.
2580
2632
  * When the session was cleared, renders a compact notice card instead.
2581
2633
  */
2582
2634
  buildConfigSwitchCard(switchResult, ctx) {
@@ -2600,62 +2652,25 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2600
2652
  },
2601
2653
  };
2602
2654
  }
2603
- // Has session → Resume card (mirrors buildAutoResumeCard)
2604
- // Guard: if the session reader for the new agent is not available (e.g. agent
2605
- // not registered), fall back to compact notice card to avoid NPE.
2606
- const readerForNewAgent = newAgent === this.config.defaultAgent
2607
- ? this.sessionReader
2608
- : this.sessionReaderRegistry.get(newAgent);
2609
- if (!readerForNewAgent) {
2610
- // Fallback: compact notice card with the notice text
2611
- const elements = [markdownDiv(switchResult.notice)];
2612
- elements.push({
2613
- tag: 'column_set',
2614
- columns: [
2615
- {
2616
- tag: 'column',
2617
- width: 'auto',
2618
- elements: [newSessionButton()],
2619
- },
2620
- ],
2621
- });
2622
- return {
2623
- card: {
2624
- schema: '2.0',
2625
- config: { wide_screen_mode: true },
2626
- header: { title: { tag: 'plain_text', content: `🔀 切换到 ${agentName}` } },
2627
- body: { elements },
2628
- },
2629
- };
2630
- }
2631
- const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionId, cwd, {
2632
- agentKind: newAgent,
2633
- maxEvents: AUTO_RESUME_MAX_EVENTS,
2634
- });
2635
- // Action buttons: stop (active) + new session
2636
- const actions = this.buildResumeActionButtons({
2637
- isActive,
2638
- 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({
2639
2659
  cwd,
2640
- newSession: true,
2641
- });
2642
- const card = buildSessionHistoryCard({
2643
2660
  sessionId,
2644
- cwd,
2645
- displayTitle,
2646
- aiTitle,
2647
- recap,
2648
- events: content,
2649
- usage,
2650
- }, {
2651
2661
  agentKind: newAgent,
2652
- headerText: `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`,
2653
- title: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
2654
- 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,
2655
2672
  emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
2656
- actions,
2657
2673
  });
2658
- return { card };
2659
2674
  }
2660
2675
  /**
2661
2676
  * Whether a session of the given agent kind can be compacted from a resume
@@ -2700,7 +2715,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2700
2715
  * @param offset 分页起点(条目数)
2701
2716
  * @param rootDir 浏览起点:卡片「返回」按钮回到这里。省略时默认 = 本次列出的
2702
2717
  * 目录(`/ls <dir>` 的起点即该目录),因此「返回」不会把用户
2703
- * 丢回 工作目录(用户明确反馈过)。
2718
+ * 丢回 工作目录。
2704
2719
  * @param q 关键词筛选:只匹配**当前层**条目名(不递归),大小写不敏感。
2705
2720
  * 省略/空 = 无筛选。
2706
2721
  */
@@ -2790,7 +2805,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2790
2805
  }
2791
2806
  cmdActive(_args, _ctx, offset = 0) {
2792
2807
  // List all active runs from lark-remote memory (not from file system scan)
2793
- // New semantics (2026-07-20): only shows runs started by THIS lark-remote process
2808
+ // (only shows runs started by THIS lark-remote process)
2794
2809
  const activeRuns = this.bridge.getActiveRuns();
2795
2810
  const activeBashRuns = this.bridge.getActiveBashRuns();
2796
2811
  if (activeRuns.length === 0 && activeBashRuns.length === 0) {
@@ -2814,54 +2829,31 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2814
2829
  const elements = [];
2815
2830
  let remaining = pageSize;
2816
2831
  let skipped = safeOffset;
2817
- // Agent runs section
2818
- if (activeRuns.length > 0 && skipped < activeRuns.length && remaining > 0) {
2819
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 🤖 Agent 任务' } });
2820
- const start = skipped;
2821
- const end = Math.min(start + remaining, activeRuns.length);
2822
- for (let i = start; i < end; i++) {
2823
- const run = activeRuns[i];
2824
- const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2825
- elements.push({
2826
- tag: 'div',
2827
- text: { tag: 'lark_md', content: `**📂 ${displayName(run.cwd)}**` },
2828
- });
2829
- elements.push({
2830
- tag: 'div',
2831
- text: {
2832
- tag: 'lark_md',
2833
- content: `> session: ${run.sessionId.slice(0, 12)}... \n> 状态: ${statusLabel}`,
2834
- },
2835
- });
2836
- elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
2837
- elements.push({ tag: 'hr' });
2838
- }
2839
- remaining -= end - start;
2840
- skipped = 0;
2841
- }
2842
- else {
2843
- skipped -= activeRuns.length;
2844
- }
2845
- // Bash runs section
2846
- if (activeBashRuns.length > 0 && skipped < activeBashRuns.length && remaining > 0) {
2847
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: '## 💻 Bash 命令' } });
2848
- const start = skipped;
2849
- const end = Math.min(start + remaining, activeBashRuns.length);
2850
- for (let i = start; i < end; i++) {
2851
- const run = activeBashRuns[i];
2852
- const statusLabel = run.terminal === 'running' ? '运行中' : '处理中';
2853
- elements.push({
2854
- tag: 'div',
2855
- text: { tag: 'lark_md', content: `**\`${run.command}\`**` },
2856
- });
2857
- elements.push({
2858
- tag: 'div',
2859
- text: { tag: 'lark_md', content: `> 目录: ${run.cwd} \n> 状态: ${statusLabel}` },
2860
- });
2861
- elements.push(cardButton('⏹ 停止', { cmd: 'stop', runId: run.runId, cwd: run.cwd }, { type: 'danger', size: 'small' }));
2862
- elements.push({ tag: 'hr' });
2863
- }
2864
- }
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);
2865
2857
  // Pagination bar(复用 paginationBar:safeOffset 已对齐页边界,
2866
2858
  // offset±pageSize 与原先 currentPage±1 的页码换算等价)
2867
2859
  if (totalPages > 1) {
@@ -2886,6 +2878,34 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2886
2878
  };
2887
2879
  return { card };
2888
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
+ }
2889
2909
  async cmdConfig(args, ctx) {
2890
2910
  // Ensure probe cache is populated before building the card.
2891
2911
  await probeAllAgents();
@@ -2912,21 +2932,21 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2912
2932
  // defaultAgent 不再漏判。
2913
2933
  const switchResult = this.propagateConfigSave(oldDefaultAgent, { [mapAgentKey(key)]: value }, ctx);
2914
2934
  this.pendingConfig = null; // 命令式写盘后清空暂存,避免不一致
2915
- // 2026-08-13: agent 切换时额外发送 Resume 卡片
2935
+ // agent 切换时额外发送 Resume 卡片
2916
2936
  if (switchResult.notice) {
2917
2937
  const switchCard = this.buildConfigSwitchCard(switchResult, ctx);
2918
2938
  // 文本路径返回 config card,Resume card 通过 bridge 异步发送
2919
2939
  void this.bridge.sendResult(switchCard, ctx);
2920
2940
  }
2921
2941
  const card = this.buildConfigCard();
2922
- if (discarded > 0 && card.card && typeof card.card === 'object') {
2942
+ if (discarded > 0) {
2943
+ // buildConfigCardFromTabs 恒返回 body.elements(至少含保存按钮),
2944
+ // 这里直接收窄插入提示条——同模块自己刚构建的值无需逐层探测。
2923
2945
  const body = card.card.body;
2924
- if (body && Array.isArray(body.elements)) {
2925
- body.elements.unshift({
2926
- tag: 'div',
2927
- text: { tag: 'lark_md', content: `⚠️ 已丢弃 ${discarded} 项未保存修改` },
2928
- });
2929
- }
2946
+ body.elements.unshift({
2947
+ tag: 'div',
2948
+ text: { tag: 'lark_md', content: `⚠️ 已丢弃 ${discarded} 项未保存修改` },
2949
+ });
2930
2950
  }
2931
2951
  return card;
2932
2952
  }