lark-remote 0.5.5 → 0.5.7

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 (58) hide show
  1. package/CHANGELOG.md +48 -7
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/approval-coordinator.d.ts +13 -0
  5. package/dist/bridge/approval-coordinator.js +34 -1
  6. package/dist/bridge/index.d.ts +48 -1
  7. package/dist/bridge/index.js +86 -14
  8. package/dist/card/approval-render.js +23 -1
  9. package/dist/card/card-budget.d.ts +37 -0
  10. package/dist/card/card-budget.js +128 -5
  11. package/dist/card/run-renderer.d.ts +24 -0
  12. package/dist/card/run-renderer.js +256 -60
  13. package/dist/card/run-state.d.ts +26 -0
  14. package/dist/card/run-state.js +45 -0
  15. package/dist/card/time.d.ts +14 -0
  16. package/dist/card/time.js +30 -0
  17. package/dist/card/tool-render.d.ts +15 -2
  18. package/dist/card/tool-render.js +40 -66
  19. package/dist/common/text-width.d.ts +24 -0
  20. package/dist/common/text-width.js +93 -0
  21. package/dist/common/tool-summary.d.ts +11 -0
  22. package/dist/common/tool-summary.js +153 -0
  23. package/dist/config/index.d.ts +1 -1
  24. package/dist/config/index.js +1 -1
  25. package/dist/connector/index.js +1 -1
  26. package/dist/index.js +63 -8
  27. package/dist/platform/command.js +1 -1
  28. package/dist/platform/shell.d.ts +2 -2
  29. package/dist/platform/spawn.d.ts +1 -1
  30. package/dist/platform/spawn.js +2 -2
  31. package/dist/platform/types.d.ts +1 -1
  32. package/dist/router/card-helpers.d.ts +18 -16
  33. package/dist/router/card-helpers.js +65 -4
  34. package/dist/router/index.d.ts +9 -0
  35. package/dist/router/index.js +57 -7
  36. package/dist/runner/claude/session.d.ts +1 -1
  37. package/dist/runner/claude/session.js +3 -3
  38. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  39. package/dist/runner/common/jsonrpc/transport.js +3 -3
  40. package/dist/runner/common/spawning-runner.d.ts +1 -1
  41. package/dist/runner/common/spawning-runner.js +2 -2
  42. package/dist/runner/dsh/auth.js +2 -1
  43. package/dist/runner/dsh/client.d.ts +124 -6
  44. package/dist/runner/dsh/client.js +398 -12
  45. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  46. package/dist/runner/dsh/exit-cancel.js +72 -0
  47. package/dist/runner/dsh/inflight.d.ts +62 -0
  48. package/dist/runner/dsh/inflight.js +140 -0
  49. package/dist/runner/dsh/runner.d.ts +117 -7
  50. package/dist/runner/dsh/runner.js +607 -21
  51. package/dist/runner/dsh/translator.d.ts +1 -1
  52. package/dist/runner/dsh/translator.js +103 -1
  53. package/dist/runner/dsh/types.d.ts +91 -1
  54. package/dist/runner/types.d.ts +86 -2
  55. package/dist/session/claude/sessions.js +2 -2
  56. package/dist/session/dsh/sessions.js +100 -15
  57. package/dist/update/install.js +1 -1
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.7] - 2026-10-20
9
+
10
+ ### 新增
11
+
12
+ - **DSH 运行的工具调用与运行状态进卡**:此前 DSH 直连的运行卡片只有正文、看不到任何工具调用。现在工具块以原生折叠面板呈现(与思考块同款交互,点标题展开、不经过服务端往返),运行中或失败的默认展开,正文按卡片内工具数量分配截断预算;摘要识别 DSH 的工具名与参数(`glob`、`web_search`、`todo_write`、`subagent`、`job_*`、`terminal_*` 等),标题显示不下时按**显示宽度**做中间省略——保住工具名与路径结尾,而不是从尾部切掉文件名。卡片还会投影 DSH 的 `session/control` 状态流(步数与上下文占用),并在首帧列出仍在后台运行的任务(终端里 `run_in_background` 起的进程活过了本轮),终态卡片可直接停止它们
13
+ - **恢复卡片能看出思路**:DSH 的历史块分 `text` / `reasoning` / `tool-call` 三类,此前只认 `text`,于是一个真实会话里 371 段思考、12 段正文的恢复卡只剩一串工具调用。现在思考映射成 thinking、正文映射成回复,工具调用与结果合成同一个面板(标题带工具名与命令,失败标 `❌`,正文是输出)
14
+ - **`/resume` 卡保住「我上一句 + 你最后一句」**:卡片只显示最后 N 个块,用户最关心的「我刚说了什么、它最后答了什么」常被中间的工具往返挤出窗口。现在这两句有独立名额
15
+
16
+ ### 修复
17
+
18
+ - **手机端窄屏排版**:状态行原先用并排短列布局,窄屏上会被挤成竖排(「运行中」三个字竖着排),且列宽由客户端决定、服务端控制不了。改为单行单块 + ` · ` 分隔,并补上本轮耗时
19
+ - **卡片元素总数上限按实测收敛**:飞书拒绝的判据是递归 `elements` 成员数量,此前记录有误。现在运行卡片同时受字节预算与元素预算约束,超限时从最旧的步骤开始省略,不再整卡发送失败
20
+ - **DSH 的停止不再假报已停止**:回合跑在共享的 `dsh web` 宿主里,退出本地进程带不走它。停止现在分两步——prompt 还没被回合取走时从宿主收件箱撤回,已被取走才发取消并用 `session/list.running` 轮询确认;无法确认时明确告知「可能仍在后台运行」,而不是报一句已停止。取消确认落地前,下一条消息由门禁拦住,避免会话级取消打到新回合;每次提交的在途身份另存一份,下次启动对账:清掉残留的待执行 prompt,对仍开着的回合只提示不代杀
21
+ - **排队与压缩的回合归属**:排队中的 prompt 此前可能在上一个回合结束时被收尾,现在绑定到自己的回合;`/compact` 执行前先探服务端是否还有在途回合;进程退出时取消尚未收尾的回合,不留无人订阅的孤儿回合
22
+ - **空闲判定改用会话状态**:此前靠事件流静默推断,工具往返密集时容易误判。现在读 `session/list.running`,事件探测降为兜底
23
+ - **回顾卡不再被系统注入内容灌满**:DSH 的 `user/message` 里混着 agent 指令、技能清单、运行期上下文、压缩检查点等注入项,此前照单全收,回顾卡上看不见用户真正说过的话。现在按类型过滤,系统通知单独标 `🔔`
24
+ - **Windows 与 CI 稳定性**:退出路径的取消与凭据检查统一走平台判定;若干用例的凭据路径断言跟随 `path.join`,不再写死 POSIX 字面量
25
+
26
+ ### 变更
27
+
28
+ - **英文文档补齐 DSH 部分**:`docs/en/architecture/design.md` 补上停止与取消确认的叙述,`docs/en/architecture/streaming-card.md` 更正工具折叠的口径(此前写的「连续工具超过阈值折叠」已被实现取代),两侧不再有互相矛盾的说法
29
+ - **用户文档纠正过期说法**:`/order` 别名的命令形态与存储位置(英文侧仍写已移除的 `/order alias <name> <text>` 与 `aliases.json`,实际是 `alias add|rm <orderId|序号>`、随指令存在 `orders.json`)、`/ws save` 示例多带一个实际不生效的路径参数、贡献指南要求 `bun run test` 而入门文档教的是 `bun test`、README 里真机用例的缺失目录前缀
30
+ - **注释与文档整理**:入站消息两篇架构文档改为描述当前实现,测试文件头不再点名已不存在的文件。无行为变化
31
+
32
+ ## [0.5.6] - 2026-10-18
33
+
34
+ ### 新增
35
+
36
+ - **DSH 的提问(`ask_user_question`)转写到飞书运行卡片**:agent 运行中提出的问题现在渲染成卡片底部的提问区——选项按钮(单选 ⚪/🔵、多选 ⬜/☑️)、「自定义答案(Other)」输入、多选时的「✅ 提交答案(已选 N 项)」,以及底部统一的「⏭️ 跳过回答」。回答按题目编码回 DSH。plan-review 那类把计划全文放在正文里的提问,卡片用折叠面板承载(超出字节预算时截断),不会让你在看不到计划的情况下做选择。飞书卡 30 分钟没人回答时只把这一路交回 DSH Web UI,不取消回合,卡片保留「可在 DSH Web UI 继续回答」的提示
37
+
38
+ ### 修复
39
+
40
+ - **断线重连后重投的提问不再让勾选与答案错位**:同一个提问在 `$events` 重连后会被重新投递,此前协调器沿用旧一路的计时、已记录的答案也不会落到新视图,卡片上勾着的选项与真正将要提交的可能不是同一份。现在重投会重新计时并回填已记录的答案
41
+ - **带前台期限(timed)的提问不再走飞书卡片**:它按自己的期限结算,卡片的 30 分钟窗口与它对不上,在卡片上答了也可能已被丢弃。现在这类提问直接交给 DSH Web UI 的倒计时处理
42
+ - **没有选项的多选题不再变成答不了的卡片**:卡片上没有任何可勾内容、而答案编码又不接受多选,等于无解。现在按可作答的自由文本题处理
43
+
44
+ ### 变更
45
+
46
+ - **英文文档补齐 DSH 部分**:`docs/en/architecture/design.md` 补上直连与凭据签发、审批与提问转写、压缩能力声明;`docs/en/usage.md` 补 `manageProcess` / `dshHome` / `dshCommand` / `startTimeoutMs` / `permissionPreset` 五项配置说明,并纠正一处过期说法——原先写着 `token` 可以在 `/config` 卡片里编辑,实际该字段早已移出卡片
47
+ - **注释与发布条目整理**。无行为变化
48
+
8
49
  ## [0.5.5] - 2026-10-18
9
50
 
10
51
  ### 新增
@@ -51,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
92
 
52
93
  ### 变更
53
94
 
54
- - **注释里的运维现场数据与仓库外引用清理**:一处测试注释记录的崩溃进程号、实例名与精确到秒的重启时间改为占位描述;若干注释不再指向仓库之外的维护规范文件名。无行为变化
95
+ - **注释里的运维数据与仓库外引用改为占位描述**。无行为变化
55
96
 
56
97
  ## [0.5.2] - 2026-10-11
57
98
 
@@ -79,7 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
79
120
 
80
121
  - **消除两处宿主环境相关的测试假红**:宿主带上本机没有的 locale(macOS 常见的 `LC_ALL=C.UTF-8`)时,bash 每次启动都往 stderr 打 setlocale 警告,让输出断言随机变红,测试 setup 阶段现在剔掉该变量(保留 UTF-8 解析所需项);JSON-RPC 退出计时用例的计时起点从进程启动改到收到 `initialize` 应答,慢速 Windows runner 上不再因启动耗时超过阈值而误红
81
122
  - **英文架构文档补齐**:`docs/en/architecture/design.md` 增补与中文侧对应的五个章节,英中两侧不再不对称
82
- - **注释与用例名清理**:延续上一版的清理,去掉剩余指向未发布文档的引用与评审编号,并修正清理过程中留下的残句。无行为变化
123
+ - **注释与用例名整理**:让公开仓库里的引用都能落地。无行为变化
83
124
 
84
125
  ## [0.5.0] - 2026-10-07
85
126
 
@@ -97,7 +138,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
97
138
 
98
139
  ### 变更
99
140
 
100
- - **注释与测试命名清理**:去掉源码、测试注释与用例名中指向未发布内部文档的引用(评审条目号、场景号、轮次号、文件名前缀),让公开仓库里的引用都能落地。无行为变化
141
+ - **注释与测试命名整理**:让公开仓库里的引用都能落地。无行为变化
101
142
 
102
143
  ## [0.4.5] - 2026-10-07
103
144
 
@@ -317,7 +358,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
317
358
 
318
359
  - ExitPlanMode 计划审批:计划全文折叠展示 + 五决策对齐 TUI(自动放行 / 附意见 / 采纳修改)
319
360
  - 卡片别名管理重构:`alias-store` 并入 `alias-resolve`,`/order` 卡片新增「+别名」交互(绑定/修改/删除,已绑定指令显示 `$name` 标签 + ✕)
320
- - DSH / connection-based runner 启动 / 配置热更 / 失败路径修复(CC-01~CC-08)
361
+ - DSH / connection-based runner 启动 / 配置热更 / 失败路径修复
321
362
 
322
363
  ### 变更
323
364
 
@@ -327,7 +368,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
327
368
  ### 修复
328
369
 
329
370
  - 别名卡片成功回调:携带 `card` 替换 pre-click 卡,删除文案对齐新交互
330
- - kimi ACP runner 模型下发:迁移到纯 ACP 后 `session/set_config_option` 必须紧跟 `session/new|resume` 下发,否则实际跑的是默认模型(CC-07)
371
+ - kimi ACP runner 模型下发:迁移到纯 ACP 后 `session/set_config_option` 必须紧跟 `session/new|resume` 下发,否则实际跑的是默认模型
331
372
 
332
373
  ## [0.1.8] - 2026-08-19
333
374
 
@@ -451,13 +492,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
451
492
  ### 测试
452
493
 
453
494
  - 合并 stub 工厂、净化 fixtures、移除冗余测试
454
- - 新增 P1-15 configContainer 锚点测试(含 codex 负向断言)
495
+ - 新增 configContainer 锚点测试(含 codex 负向断言)
455
496
 
456
497
  ## [0.1.2] - 2026-08-10
457
498
 
458
499
  ### 修复
459
500
 
460
- - claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效(P1-15)
501
+ - claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效
461
502
  - codex / kimi 的 `readSessionContent` 增加 cwd 校验,防止越界读取会话
462
503
  - `listClaudeSessions` / `isClaudeSessionActive` 支持跨目录回退,处理会话迁移后的定位
463
504
  - 修复 `handleQueueInput` 竞态:替换任务在 await 之前注册
package/README.en.md CHANGED
@@ -137,8 +137,8 @@ bun run lint # eslint
137
137
 
138
138
  # Live Feishu API integration tests (skipped by default; need valid
139
139
  # credentials under ~/.lark-remote-test)
140
- FEISHU_LIVE_TEST=1 bun run test tests/feishu-card-form-error.test.ts
141
- FEISHU_LIVE_TEST=1 bun run test tests/feishu-reaction-emoji-live.test.ts
140
+ FEISHU_LIVE_TEST=1 bun run test tests/feishu-live/feishu-card-form-error.test.ts
141
+ FEISHU_LIVE_TEST=1 bun run test tests/feishu-live/feishu-reaction-emoji-live.test.ts
142
142
  ```
143
143
 
144
144
  ## Documentation
package/README.md CHANGED
@@ -141,8 +141,8 @@ bun run typecheck # tsc --noEmit
141
141
  bun run lint # eslint
142
142
 
143
143
  # 真实飞书 API 集成测试(默认跳过,需要 ~/.lark-remote-test 下的有效凭据)
144
- FEISHU_LIVE_TEST=1 bun run test tests/feishu-card-form-error.test.ts
145
- FEISHU_LIVE_TEST=1 bun run test tests/feishu-reaction-emoji-live.test.ts
144
+ FEISHU_LIVE_TEST=1 bun run test tests/feishu-live/feishu-card-form-error.test.ts
145
+ FEISHU_LIVE_TEST=1 bun run test tests/feishu-live/feishu-reaction-emoji-live.test.ts
146
146
  ```
147
147
 
148
148
  ## 文档
@@ -163,6 +163,19 @@ export declare class ApprovalCoordinator {
163
163
  * 用户看到失败却没有任何重试入口。
164
164
  */
165
165
  private failSubmission;
166
+ /**
167
+ * Re-apply the answers already recorded for a question batch onto a
168
+ * re-delivered view.
169
+ *
170
+ * A re-delivered waterfall (DSH replays pending waterfalls to a reconnected
171
+ * `$events` client) rebuilds the view from the agent payload, which carries no
172
+ * card-local state: a plain replace would drop the checkmarks of questions the
173
+ * user already answered while `answers` still counts them, so the card would
174
+ * contradict what a submit sends. Only recorded answers (`answers` / `notes`)
175
+ * are restored; unsubmitted multi-select toggles are card-local by design.
176
+ * Alignment is by index: the same requestId means the same batch.
177
+ */
178
+ private reapplyRecordedAnswers;
166
179
  /** 记录问题答案到 tracked.answers(懒初始化 Map)。 */
167
180
  private recordAnswer;
168
181
  private getPendingQuestion;
@@ -38,7 +38,7 @@ export class ApprovalCoordinator {
38
38
  if (existing) {
39
39
  // Already tracked — update state
40
40
  existing.state = 'pending';
41
- existing.view = event.view;
41
+ existing.view = this.reapplyRecordedAnswers(existing, event.view);
42
42
  existing.timeoutMs = timeoutMs;
43
43
  // 重投必须重新计时:上一路的计时器在 resolved/expired 时已清掉,不重装
44
44
  // 这条审批就永不过期,server 只能无限等待(红线「审批超时自动 cancel」)。
@@ -392,6 +392,39 @@ export class ApprovalCoordinator {
392
392
  this.armTimeout(tracked, tracked.timeoutMs);
393
393
  this.pushApprovalViewUpdate(tracked.requestId, tracked);
394
394
  }
395
+ /**
396
+ * Re-apply the answers already recorded for a question batch onto a
397
+ * re-delivered view.
398
+ *
399
+ * A re-delivered waterfall (DSH replays pending waterfalls to a reconnected
400
+ * `$events` client) rebuilds the view from the agent payload, which carries no
401
+ * card-local state: a plain replace would drop the checkmarks of questions the
402
+ * user already answered while `answers` still counts them, so the card would
403
+ * contradict what a submit sends. Only recorded answers (`answers` / `notes`)
404
+ * are restored; unsubmitted multi-select toggles are card-local by design.
405
+ * Alignment is by index: the same requestId means the same batch.
406
+ */
407
+ reapplyRecordedAnswers(tracked, view) {
408
+ if (view.kind !== 'question' || tracked.kind !== 'question')
409
+ return view;
410
+ const questions = view.questions ?? [];
411
+ if (questions.length === 0)
412
+ return view;
413
+ return {
414
+ ...view,
415
+ questions: questions.map((question, index) => {
416
+ const selected = tracked.answers?.get(index);
417
+ const note = tracked.notes?.get(index);
418
+ if (selected === undefined && note === undefined)
419
+ return question;
420
+ return {
421
+ ...question,
422
+ ...(selected === undefined ? {} : { selected }),
423
+ ...(note === undefined ? {} : { note }),
424
+ };
425
+ }),
426
+ };
427
+ }
395
428
  /** 记录问题答案到 tracked.answers(懒初始化 Map)。 */
396
429
  recordAnswer(tracked, questionIndex, values) {
397
430
  tracked.answers ??= new Map();
@@ -4,7 +4,7 @@ import type { SessionReaderRegistry, SessionStore } from '../session/index.js';
4
4
  import type { AppConfig } from '../config/index.js';
5
5
  import { type RunCardChannel } from '../card/run-card-session.js';
6
6
  import type { RunTerminal } from '../card/run-state.js';
7
- import { type AgentKind } from '../runner/types.js';
7
+ import { type AgentKind, type StopOutcome } from '../runner/types.js';
8
8
  import { QueueManager, type QueuedTask, type EnqueueOptions, type AgentBinding, type ExecutingInfo } from './queue-manager.js';
9
9
  import type { InboundMediaPayload } from '../connector/index.js';
10
10
  import type { MediaOutcome } from '../inbound/turn.js';
@@ -46,6 +46,13 @@ export interface CommandResult {
46
46
  markdown?: string;
47
47
  card?: object;
48
48
  }
49
+ /** Detailed result of one interrupt, for callers that must not over-promise. */
50
+ export interface InterruptOutcome {
51
+ stopped: boolean;
52
+ agentKind?: AgentKind;
53
+ /** Present for DSH: what the remote stop actually achieved. */
54
+ stop?: StopOutcome;
55
+ }
49
56
  export interface ActiveRunSnapshot {
50
57
  runId: string;
51
58
  userId: string;
@@ -151,6 +158,16 @@ export declare class Bridge {
151
158
  * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
152
159
  */
153
160
  private compactableRuns;
161
+ /**
162
+ * 终态卡片的渲染快照(runId → state + options),供「详情」在 run 结束后
163
+ * 仍能二次渲染。
164
+ *
165
+ * run 结束时 activeRuns 被清空,而卡片上的「详情」按钮还活着;没有快照,
166
+ * 点终态卡上的「详情」只能得到一句「已失效」。只保留最近若干条:单条快照
167
+ * 受 MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡。
168
+ */
169
+ private runCardSnapshots;
170
+ private static readonly RUN_CARD_SNAPSHOT_MAX;
154
171
  /**
155
172
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
156
173
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -318,6 +335,20 @@ export declare class Bridge {
318
335
  * omitted, all workspaces are candidates (used by /stop, /t, and card stop button which don't know which cwd to target). */
319
336
  cwd?: string;
320
337
  }): Promise<boolean>;
338
+ /**
339
+ * Same as {@link interruptCurrentRun}, plus what the runner's stop actually
340
+ * achieved. DSH returns a {@link StopOutcome} (withdrawn / cancelled /
341
+ * unconfirmed); process runners and bash return none.
342
+ *
343
+ * Callers use `stop.status === 'unconfirmed'` to avoid promising "stopped" or
344
+ * "executing immediately" when a turn may still be running in the host.
345
+ */
346
+ interruptCurrentRunDetailed(input: {
347
+ userId: string;
348
+ chatId: string;
349
+ runId?: string;
350
+ cwd?: string;
351
+ }): Promise<InterruptOutcome>;
321
352
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
322
353
  sendResult(result: CommandResult, ctx: CommandContext): Promise<boolean>;
323
354
  /**
@@ -345,6 +376,22 @@ export declare class Bridge {
345
376
  * card id from their set on `false`.
346
377
  */
347
378
  patchCard(messageId: string, card: object): Promise<boolean>;
379
+ /**
380
+ * 终态快照的写入点:finalize 时把当前 state + 渲染选项留一份。
381
+ *
382
+ * 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
383
+ * 与 activeRuns 生命周期在同一个地方收口。
384
+ */
385
+ private rememberRunCardSnapshot;
386
+ /**
387
+ * 停止终态卡上的一个后台任务。
388
+ *
389
+ * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
390
+ * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
391
+ * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
392
+ * 会留在卡片上而任务早已结束。
393
+ */
394
+ killBackgroundJob(runId: string, jobId: string, ctx: CommandContext): Promise<boolean>;
348
395
  /** Send a file to the user via Feishu. */
349
396
  sendFile(filePath: string, ctx: CommandContext): Promise<void>;
350
397
  /**
@@ -150,6 +150,16 @@ export class Bridge {
150
150
  * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
151
151
  */
152
152
  compactableRuns = new Map();
153
+ /**
154
+ * 终态卡片的渲染快照(runId → state + options),供「详情」在 run 结束后
155
+ * 仍能二次渲染。
156
+ *
157
+ * run 结束时 activeRuns 被清空,而卡片上的「详情」按钮还活着;没有快照,
158
+ * 点终态卡上的「详情」只能得到一句「已失效」。只保留最近若干条:单条快照
159
+ * 受 MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡。
160
+ */
161
+ runCardSnapshots = new Map();
162
+ static RUN_CARD_SNAPSHOT_MAX = 8;
153
163
  /**
154
164
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
155
165
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -575,6 +585,17 @@ export class Bridge {
575
585
  return result;
576
586
  }
577
587
  async interruptCurrentRun(input) {
588
+ return (await this.interruptCurrentRunDetailed(input)).stopped;
589
+ }
590
+ /**
591
+ * Same as {@link interruptCurrentRun}, plus what the runner's stop actually
592
+ * achieved. DSH returns a {@link StopOutcome} (withdrawn / cancelled /
593
+ * unconfirmed); process runners and bash return none.
594
+ *
595
+ * Callers use `stop.status === 'unconfirmed'` to avoid promising "stopped" or
596
+ * "executing immediately" when a turn may still be running in the host.
597
+ */
598
+ async interruptCurrentRunDetailed(input) {
578
599
  // Find the active claude run matching this user/chat
579
600
  for (const [cwd, active] of this.activeRuns) {
580
601
  if (input.cwd !== undefined && cwd !== input.cwd)
@@ -595,13 +616,18 @@ export class Bridge {
595
616
  // unconditional reset would then zero the successor's count and mark
596
617
  // its slot interrupted, hiding it from queue cards.
597
618
  const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
598
- await Promise.allSettled([
619
+ const [, stopResult] = await Promise.allSettled([
599
620
  // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
600
621
  // interruptedReason,与审批超时自动取消(approval_timeout)、审批
601
622
  // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
602
623
  active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
603
624
  runner.stop({ immediate: true }),
604
625
  ]);
626
+ const stop = stopResult.status === 'fulfilled' &&
627
+ stopResult.value !== null &&
628
+ typeof stopResult.value === 'object'
629
+ ? stopResult.value
630
+ : undefined;
605
631
  // The chain may have advanced while the stop was in flight: a new run
606
632
  // (from a task that began during the stop window) may now occupy this
607
633
  // cwd. Only delete the entry we actually stopped.
@@ -641,7 +667,7 @@ export class Bridge {
641
667
  this.queueManager.resetExecutingCount(cwd, stoppedSlot);
642
668
  }
643
669
  getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
644
- return true;
670
+ return { stopped: true, agentKind: active.agentKind, ...(stop ? { stop } : {}) };
645
671
  }
646
672
  }
647
673
  // Then check active bash runs (! commands). These bypass the serial queue
@@ -658,11 +684,11 @@ export class Bridge {
658
684
  await b.cardSession.finish('interrupted', { exitCode: -1 });
659
685
  this.activeBashRuns.delete(runId);
660
686
  getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
661
- return true;
687
+ return { stopped: true };
662
688
  }
663
689
  }
664
690
  getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
665
- return false;
691
+ return { stopped: false };
666
692
  }
667
693
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
668
694
  async sendResult(result, ctx) {
@@ -756,6 +782,47 @@ export class Bridge {
756
782
  return false;
757
783
  }
758
784
  }
785
+ /**
786
+ * 终态快照的写入点:finalize 时把当前 state + 渲染选项留一份。
787
+ *
788
+ * 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
789
+ * 与 activeRuns 生命周期在同一个地方收口。
790
+ */
791
+ rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
792
+ this.runCardSnapshots.set(runId, { state, options, cwd, agentKind });
793
+ while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
794
+ const oldest = this.runCardSnapshots.keys().next().value;
795
+ if (oldest === undefined)
796
+ break;
797
+ this.runCardSnapshots.delete(oldest);
798
+ }
799
+ }
800
+ /**
801
+ * 停止终态卡上的一个后台任务。
802
+ *
803
+ * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
804
+ * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
805
+ * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
806
+ * 会留在卡片上而任务早已结束。
807
+ */
808
+ async killBackgroundJob(runId, jobId, ctx) {
809
+ const snapshot = this.runCardSnapshots.get(runId);
810
+ const sessionId = snapshot?.state.sessionId;
811
+ if (!snapshot || !sessionId)
812
+ return false;
813
+ const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
814
+ if (typeof runner.killJob !== 'function')
815
+ return false;
816
+ const killed = await runner.killJob(sessionId, jobId);
817
+ if (!killed)
818
+ return false;
819
+ snapshot.state = {
820
+ ...snapshot.state,
821
+ backgroundJobs: (snapshot.state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
822
+ };
823
+ await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
824
+ return true;
825
+ }
759
826
  /** Send a file to the user via Feishu. */
760
827
  async sendFile(filePath, ctx) {
761
828
  try {
@@ -1222,7 +1289,10 @@ export class Bridge {
1222
1289
  await r.respondApproval(requestId, response);
1223
1290
  }
1224
1291
  },
1225
- interruptTurn: () => runner.stop(),
1292
+ interruptTurn: async () => {
1293
+ // DSH may return a StopOutcome; the coordinator only awaits.
1294
+ await runner.stop();
1295
+ },
1226
1296
  pushToCard: async (events) => {
1227
1297
  for (const ev of events)
1228
1298
  await cardSession.push(ev);
@@ -1409,17 +1479,19 @@ export class Bridge {
1409
1479
  try {
1410
1480
  getLogger().debug(`[lark-remote] finally settle runId=${runId} state.terminal=${cardSession.currentState.terminal}`);
1411
1481
  const settleResult = await cardSession.settle();
1482
+ // 卡片收尾后仍要能点「详情」:把终态 state 与当时的渲染选项留一份。
1483
+ // 与 fallback 卡共用同一份 options(能力门控一致,展开后按钮语义不变)。
1484
+ const finalRenderOptions = {
1485
+ agentKind: activeRun.agentKind,
1486
+ // 与 createRunSession 的 renderOptions 一致:fallback 卡也要按
1487
+ // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1488
+ // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1489
+ compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1490
+ };
1491
+ this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1412
1492
  let runCardSent = false;
1413
1493
  if (settleResult === 'unsent') {
1414
- const sent = await this.sendResult({
1415
- card: renderRunCard(cardSession.currentState, {
1416
- agentKind: activeRun.agentKind,
1417
- // 与 createRunSession 的 renderOptions 一致:fallback 卡也要按
1418
- // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1419
- // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1420
- compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1421
- }),
1422
- }, ctx);
1494
+ const sent = await this.sendResult({ card: renderRunCard(cardSession.currentState, finalRenderOptions) }, ctx);
1423
1495
  runCardSent = sent;
1424
1496
  }
1425
1497
  else {
@@ -399,10 +399,19 @@ function renderQuestionApproval(approval, expired, runId) {
399
399
  });
400
400
  }
401
401
  if (expired) {
402
+ // 提问不是权限审批:过期后 agent 侧要么已按跳过继续、要么转由 Web UI
403
+ // 回答,统一文案不能写成「审批已过期」。是否还能在别处回答由数据驱动
404
+ // (DSH 可继续在 Web UI 答,Claude/Codex 不能),渲染层不写死语义。
402
405
  elements.push({
403
406
  tag: 'div',
404
- text: { tag: 'lark_md', content: '⏰ **审批已过期**' },
407
+ text: { tag: 'lark_md', content: '⏰ **提问已超时**' },
405
408
  });
409
+ if (approval.expiredHint) {
410
+ elements.push({
411
+ tag: 'div',
412
+ text: { tag: 'lark_md', content: approval.expiredHint.replace(/`/g, '·') },
413
+ });
414
+ }
406
415
  return elements;
407
416
  }
408
417
  const questions = approval.questions ?? [];
@@ -418,6 +427,19 @@ function renderQuestionApproval(approval, expired, runId) {
418
427
  content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${q.question}`,
419
428
  },
420
429
  });
430
+ if (q.detail) {
431
+ // 题面外的补充正文(DSH plan-review 提问把计划全文放这里):必须随题
432
+ // 渲染,否则用户是在「看不见计划」的情况下批准。与 ExitPlanMode 的
433
+ // 计划面板同口径:反引号中和 + 表格限数 + 字节预算折叠展示。
434
+ const neutralized = q.detail.replace(/`/g, '·');
435
+ const content = truncateUtf8(truncateMarkdownTables(neutralized), PLAN_PANEL_BYTES);
436
+ elements.push(collapsibleMarkdownPanel({
437
+ title: '📋 详情',
438
+ expanded: false,
439
+ border: 'blue',
440
+ content,
441
+ }));
442
+ }
421
443
  if (freeText) {
422
444
  // 自由文本题(Codex options:null / Pi extension input):无选项行,
423
445
  // 直接渲染题面 + 输入框,提交走 answerCustom(coordinator 不要求有选项)。
@@ -6,6 +6,43 @@
6
6
  *
7
7
  * Docs: https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukz/feishu-cards/card-components/containers/collapsible-panel
8
8
  */
9
+ /**
10
+ * 飞书单卡「元素」总量上限(实测)。
11
+ *
12
+ * 2026-10-19 用真实 API 在测试会话探测(`tests/feishu-live/feishu-card-element-total-limit.test.ts`
13
+ * 与 `feishu-workspace-card-limit` / `feishu-order-active-card-limit`):
14
+ *
15
+ * | 形状 | 结果 | elements 计数 |
16
+ * | --- | --- | --- |
17
+ * | 顶层平铺 99 个 div | ✅ | 99 |
18
+ * | 顶层平铺 100 个 div | ❌ 11310 | 100 |
19
+ * | 单列 column_set 内嵌 98 个 div | ✅ | 99 |
20
+ * | 单列 column_set 内嵌 99 个 div | ❌ | 100 |
21
+ * | `/order` 旧结构 20 行 | ❌ | 103 |
22
+ * | `/ws` 旧结构 20 行 | ❌ | 101 |
23
+ * | `/ws` 分页 15 行(整卡 227 个 JSON 对象) | ✅ | 80 |
24
+ *
25
+ * 判据是**递归统计 `elements` 数组成员**(不含 `columns`)**≤ 99**:所有实测的
26
+ * 成功/失败点都被它分开,而"整卡 JSON 对象数"分不开(`/ws` 分页卡 227 个对象
27
+ * 能发出去,平铺 100 个 div 只有 205 个对象却失败)。
28
+ *
29
+ * 另一个独立维度:单个 `column_set` 的列数(实测 66 列通过、67 列失败)。本仓库
30
+ * 所有卡片的列数都在 3 以内,用不到这个上限,仅在此记录以免再次误判。
31
+ *
32
+ * **旧结论勘误**:仓库曾把 `230025` 记为「elements 过多」。本次探测中它一次都
33
+ * 没有出现;元素超限统一是 `230099 / ErrCode: 11310 element exceeds the limit`。
34
+ *
35
+ * `MAX_CARD_ELEMENTS` 取 90(相对实测 99 留 10% 余量),用于渲染前的保守裁剪。
36
+ */
37
+ export declare const MAX_CARD_ELEMENTS = 90;
38
+ /**
39
+ * 卡片递归的 `elements` 数组成员数(`columns` 不计数)——与飞书实测边界对齐。
40
+ *
41
+ * 注意与"JSON 对象总数"不同:`column_set` 的 `column` 对象不计入,button 的
42
+ * `behaviors`/`value` 也不计入。用它做预算才既不误伤能发出去的卡片,也不放过
43
+ * 真正超限的卡片。
44
+ */
45
+ export declare function countCardElements(card: unknown): number;
9
46
  interface CardBudgetOptions {
10
47
  /** 最大事件面板数量(保留最新的) */
11
48
  maxEventPanels?: number;