lark-remote 0.5.6 → 0.5.8

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 (85) hide show
  1. package/CHANGELOG.md +46 -6
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/index.d.ts +63 -1
  5. package/dist/bridge/index.js +147 -14
  6. package/dist/bridge/queue-manager.js +41 -58
  7. package/dist/card/approval-render.js +2 -1
  8. package/dist/card/bash-card-session.d.ts +0 -1
  9. package/dist/card/bash-card-session.js +0 -8
  10. package/dist/card/bash-renderer.js +2 -1
  11. package/dist/card/card-budget.d.ts +37 -0
  12. package/dist/card/card-budget.js +143 -24
  13. package/dist/card/card-session.d.ts +3 -13
  14. package/dist/card/card-session.js +4 -18
  15. package/dist/card/card-shared.d.ts +11 -0
  16. package/dist/card/card-shared.js +19 -13
  17. package/dist/card/run-card-session.d.ts +0 -1
  18. package/dist/card/run-card-session.js +0 -6
  19. package/dist/card/run-renderer.d.ts +18 -0
  20. package/dist/card/run-renderer.js +330 -62
  21. package/dist/card/run-state.d.ts +26 -0
  22. package/dist/card/run-state.js +45 -0
  23. package/dist/card/text-truncate.d.ts +3 -5
  24. package/dist/card/text-truncate.js +3 -5
  25. package/dist/card/time.d.ts +14 -0
  26. package/dist/card/time.js +30 -0
  27. package/dist/card/tool-render.d.ts +15 -2
  28. package/dist/card/tool-render.js +42 -82
  29. package/dist/common/text-width.d.ts +24 -0
  30. package/dist/common/text-width.js +93 -0
  31. package/dist/common/tool-summary.d.ts +19 -0
  32. package/dist/common/tool-summary.js +171 -0
  33. package/dist/common/turn-failure.d.ts +13 -0
  34. package/dist/common/turn-failure.js +18 -0
  35. package/dist/config/index.d.ts +1 -1
  36. package/dist/config/index.js +1 -1
  37. package/dist/connector/index.js +1 -1
  38. package/dist/index.js +66 -13
  39. package/dist/order/index.d.ts +2 -0
  40. package/dist/order/index.js +2 -0
  41. package/dist/platform/command.js +1 -1
  42. package/dist/platform/shell.d.ts +2 -2
  43. package/dist/platform/spawn.d.ts +1 -1
  44. package/dist/platform/spawn.js +2 -2
  45. package/dist/platform/types.d.ts +1 -1
  46. package/dist/router/card-helpers.d.ts +18 -16
  47. package/dist/router/card-helpers.js +88 -46
  48. package/dist/router/config/codex.js +2 -3
  49. package/dist/router/config/common/model-patch.d.ts +8 -0
  50. package/dist/router/config/common/model-patch.js +13 -0
  51. package/dist/router/config/common/render.js +30 -77
  52. package/dist/router/config/dsh.js +2 -1
  53. package/dist/router/config/kimi.js +3 -3
  54. package/dist/router/index.d.ts +9 -11
  55. package/dist/router/index.js +165 -356
  56. package/dist/router/order-exec-dispatch.js +2 -1
  57. package/dist/runner/claude/session.d.ts +1 -1
  58. package/dist/runner/claude/session.js +3 -3
  59. package/dist/runner/common/defaults.d.ts +2 -0
  60. package/dist/runner/common/defaults.js +3 -0
  61. package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
  62. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  63. package/dist/runner/common/jsonrpc/transport.js +3 -3
  64. package/dist/runner/common/spawning-runner.d.ts +1 -1
  65. package/dist/runner/common/spawning-runner.js +2 -2
  66. package/dist/runner/dsh/auth.js +2 -1
  67. package/dist/runner/dsh/client.d.ts +100 -3
  68. package/dist/runner/dsh/client.js +290 -6
  69. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  70. package/dist/runner/dsh/exit-cancel.js +72 -0
  71. package/dist/runner/dsh/inflight.d.ts +62 -0
  72. package/dist/runner/dsh/inflight.js +140 -0
  73. package/dist/runner/dsh/runner.d.ts +90 -4
  74. package/dist/runner/dsh/runner.js +436 -18
  75. package/dist/runner/dsh/translator.d.ts +1 -1
  76. package/dist/runner/dsh/translator.js +103 -1
  77. package/dist/runner/dsh/types.d.ts +60 -0
  78. package/dist/runner/types.d.ts +95 -2
  79. package/dist/runner/types.js +9 -0
  80. package/dist/session/claude/session-index.d.ts +0 -4
  81. package/dist/session/claude/session-index.js +0 -8
  82. package/dist/session/claude/sessions.js +2 -2
  83. package/dist/session/dsh/sessions.js +157 -17
  84. package/dist/update/install.js +1 -1
  85. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.8] - 2026-10-21
9
+
10
+ ### 修复
11
+
12
+ - **DSH 的 `/resume` 列表卡看得出会话内容**:此前 DSH 会话的列表卡只有 session id 和时间,必须逐条点进详情才知道哪一条是要找的会话。DSH 0.2.x 的列表响应里已带着会话标题与每轮的输入/输出,现在把它们映射成「标题 / 最近输入 / 最后输出」直接渲染,行上有预览就不再逐条去拉分页详情(那是上万条事件的大响应);字段缺失或标题只是占位符时回退原有行为。详情卡头部同样优先用服务端的末轮输入作「最近输入」,并在它与会话标题不同时分成两段展示,不再把用户自己说的话挂在「AI 标题」下面
13
+ - **后台任务卡里的命令不再被截断**:活过本轮的后台任务(终端里起的后台进程)原先按固定字数截断,用户没法核对卡上那条命令到底是要停哪个。现在短命令内联展示、中等长度用代码块整段展示、只有确实很长的脚本或链式命令才收进折叠面板,且面板里保留完整原文——命令是你都要核对或复制的对象,任何情况下不再 truncate
14
+ - **停止按钮不再被挤成「🟥…」**:命令与停止按钮原先并排同一行(命令列 `grow`、按钮列 `auto`),长命令的最小内容宽度会把按钮压成一个色块。现在命令独占一行,停止按钮在下一行独占一列
15
+ - **运行卡片收尾后保留状态行**:终态原先会把「⏱ 耗时 · 🧰 步数 · 📊 上下文」整行去掉,回看卡片时看不出这一跑花了多久、做了多少步。现在保留:耗时定格,步数与上下文显示该回合最后一次投影的快照
16
+
17
+ ### 变更
18
+
19
+ - **模型输出被服务方内容审核截断时不再算「运行出错」,并会自动续跑一次**:上游把 `finish_reason: content_filter` 归一成普通错误,它既不在默认的重试判据里(空响应/限流/服务错误/超时/传输),也不会触发换模型,于是一次多为误报的判定就把整个回合判死——跑了很久的回合在最后一步被截断,已写入磁盘的改动都还在,卡片上却只有一句英文内部报文。现在错误卡说明三件事:谁拦的、改动还在、下一步可做什么(重试仍被拦截时换模型或稍后再试);桥接层同时在同一会话补一条「从上次中断的位置继续,不要重复已完成的步骤」,只补一次,且若运行期间会话指针已被 `/new`、`/resume` 移动过就跳过,避免把旧会话接回来
20
+ - **DSH 在卡片上的显示名统一为 `DSH`**:各 agent 的显示名收敛到同一张表,启动注册与没有注册表时的回退路径共用同一份数据,不再出现同一 agent 在不同位置叫法不同
21
+ - **重复实现单源化,无行为变化**:档位取中位、工具摘要、卡片头部/按钮/字段行、队列管理与配置卡渲染等此前写了两遍的实现合并为单一来源;测试专用生产钩子移除;同主题测试套件合并、共享夹具抽出(用例覆盖不减)
22
+ - **文档里的测试锚点改指存活文件**:套件合并删掉了若干测试文件,贡献指南与架构设计文档按路径点名的锚点因此指向不存在的路径,已改指承接其覆盖的文件。无行为变化
23
+
24
+ ## [0.5.7] - 2026-10-20
25
+
26
+ ### 新增
27
+
28
+ - **DSH 运行的工具调用与运行状态进卡**:此前 DSH 直连的运行卡片只有正文、看不到任何工具调用。现在工具块以原生折叠面板呈现(与思考块同款交互,点标题展开、不经过服务端往返),运行中或失败的默认展开,正文按卡片内工具数量分配截断预算;摘要识别 DSH 的工具名与参数(`glob`、`web_search`、`todo_write`、`subagent`、`job_*`、`terminal_*` 等),标题显示不下时按**显示宽度**做中间省略——保住工具名与路径结尾,而不是从尾部切掉文件名。卡片还会投影 DSH 的 `session/control` 状态流(步数与上下文占用),并在首帧列出仍在后台运行的任务(终端里 `run_in_background` 起的进程活过了本轮),终态卡片可直接停止它们
29
+ - **恢复卡片能看出思路**:DSH 的历史块分 `text` / `reasoning` / `tool-call` 三类,此前只认 `text`,于是一个真实会话里 371 段思考、12 段正文的恢复卡只剩一串工具调用。现在思考映射成 thinking、正文映射成回复,工具调用与结果合成同一个面板(标题带工具名与命令,失败标 `❌`,正文是输出)
30
+ - **`/resume` 卡保住「我上一句 + 你最后一句」**:卡片只显示最后 N 个块,用户最关心的「我刚说了什么、它最后答了什么」常被中间的工具往返挤出窗口。现在这两句有独立名额
31
+
32
+ ### 修复
33
+
34
+ - **手机端窄屏排版**:状态行原先用并排短列布局,窄屏上会被挤成竖排(「运行中」三个字竖着排),且列宽由客户端决定、服务端控制不了。改为单行单块 + ` · ` 分隔,并补上本轮耗时
35
+ - **卡片元素总数上限按实测收敛**:飞书拒绝的判据是递归 `elements` 成员数量,此前记录有误。现在运行卡片同时受字节预算与元素预算约束,超限时从最旧的步骤开始省略,不再整卡发送失败
36
+ - **DSH 的停止不再假报已停止**:回合跑在共享的 `dsh web` 宿主里,退出本地进程带不走它。停止现在分两步——prompt 还没被回合取走时从宿主收件箱撤回,已被取走才发取消并用 `session/list.running` 轮询确认;无法确认时明确告知「可能仍在后台运行」,而不是报一句已停止。取消确认落地前,下一条消息由门禁拦住,避免会话级取消打到新回合;每次提交的在途身份另存一份,下次启动对账:清掉残留的待执行 prompt,对仍开着的回合只提示不代杀
37
+ - **排队与压缩的回合归属**:排队中的 prompt 此前可能在上一个回合结束时被收尾,现在绑定到自己的回合;`/compact` 执行前先探服务端是否还有在途回合;进程退出时取消尚未收尾的回合,不留无人订阅的孤儿回合
38
+ - **空闲判定改用会话状态**:此前靠事件流静默推断,工具往返密集时容易误判。现在读 `session/list.running`,事件探测降为兜底
39
+ - **回顾卡不再被系统注入内容灌满**:DSH 的 `user/message` 里混着 agent 指令、技能清单、运行期上下文、压缩检查点等注入项,此前照单全收,回顾卡上看不见用户真正说过的话。现在按类型过滤,系统通知单独标 `🔔`
40
+ - **Windows 与 CI 稳定性**:退出路径的取消与凭据检查统一走平台判定;若干用例的凭据路径断言跟随 `path.join`,不再写死 POSIX 字面量
41
+
42
+ ### 变更
43
+
44
+ - **英文文档补齐 DSH 部分**:`docs/en/architecture/design.md` 补上停止与取消确认的叙述,`docs/en/architecture/streaming-card.md` 更正工具折叠的口径(此前写的「连续工具超过阈值折叠」已被实现取代),两侧不再有互相矛盾的说法
45
+ - **用户文档纠正过期说法**:`/order` 别名的命令形态与存储位置(英文侧仍写已移除的 `/order alias <name> <text>` 与 `aliases.json`,实际是 `alias add|rm <orderId|序号>`、随指令存在 `orders.json`)、`/ws save` 示例多带一个实际不生效的路径参数、贡献指南要求 `bun run test` 而入门文档教的是 `bun test`、README 里真机用例的缺失目录前缀
46
+ - **注释与文档整理**:入站消息两篇架构文档改为描述当前实现,测试文件头不再点名已不存在的文件。无行为变化
47
+
8
48
  ## [0.5.6] - 2026-10-18
9
49
 
10
50
  ### 新增
@@ -20,7 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
60
  ### 变更
21
61
 
22
62
  - **英文文档补齐 DSH 部分**:`docs/en/architecture/design.md` 补上直连与凭据签发、审批与提问转写、压缩能力声明;`docs/en/usage.md` 补 `manageProcess` / `dshHome` / `dshCommand` / `startTimeoutMs` / `permissionPreset` 五项配置说明,并纠正一处过期说法——原先写着 `token` 可以在 `/config` 卡片里编辑,实际该字段早已移出卡片
23
- - **注释与发布条目里的内部评审轮次标注清理**。无行为变化
63
+ - **注释与发布条目整理**。无行为变化
24
64
 
25
65
  ## [0.5.5] - 2026-10-18
26
66
 
@@ -68,7 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
68
108
 
69
109
  ### 变更
70
110
 
71
- - **注释里的运维现场数据与仓库外引用清理**:一处测试注释记录的崩溃进程号、实例名与精确到秒的重启时间改为占位描述;若干注释不再指向仓库之外的维护规范文件名。无行为变化
111
+ - **注释里的运维数据与仓库外引用改为占位描述**。无行为变化
72
112
 
73
113
  ## [0.5.2] - 2026-10-11
74
114
 
@@ -96,7 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
96
136
 
97
137
  - **消除两处宿主环境相关的测试假红**:宿主带上本机没有的 locale(macOS 常见的 `LC_ALL=C.UTF-8`)时,bash 每次启动都往 stderr 打 setlocale 警告,让输出断言随机变红,测试 setup 阶段现在剔掉该变量(保留 UTF-8 解析所需项);JSON-RPC 退出计时用例的计时起点从进程启动改到收到 `initialize` 应答,慢速 Windows runner 上不再因启动耗时超过阈值而误红
98
138
  - **英文架构文档补齐**:`docs/en/architecture/design.md` 增补与中文侧对应的五个章节,英中两侧不再不对称
99
- - **注释与用例名清理**:延续上一版的清理,去掉剩余指向未发布文档的引用与评审编号,并修正清理过程中留下的残句。无行为变化
139
+ - **注释与用例名整理**:让公开仓库里的引用都能落地。无行为变化
100
140
 
101
141
  ## [0.5.0] - 2026-10-07
102
142
 
@@ -114,7 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
114
154
 
115
155
  ### 变更
116
156
 
117
- - **注释与测试命名清理**:去掉源码、测试注释与用例名中指向未发布内部文档的引用(评审条目号、场景号、轮次号、文件名前缀),让公开仓库里的引用都能落地。无行为变化
157
+ - **注释与测试命名整理**:让公开仓库里的引用都能落地。无行为变化
118
158
 
119
159
  ## [0.4.5] - 2026-10-07
120
160
 
@@ -468,13 +508,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
468
508
  ### 测试
469
509
 
470
510
  - 合并 stub 工厂、净化 fixtures、移除冗余测试
471
- - 新增 P1-15 configContainer 锚点测试(含 codex 负向断言)
511
+ - 新增 configContainer 锚点测试(含 codex 负向断言)
472
512
 
473
513
  ## [0.1.2] - 2026-08-10
474
514
 
475
515
  ### 修复
476
516
 
477
- - claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效(P1-15)
517
+ - claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效
478
518
  - codex / kimi 的 `readSessionContent` 增加 cwd 校验,防止越界读取会话
479
519
  - `listClaudeSessions` / `isClaudeSessionActive` 支持跨目录回退,处理会话迁移后的定位
480
520
  - 修复 `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
  ## 文档
@@ -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
  /**
@@ -369,7 +416,22 @@ export declare class Bridge {
369
416
  forwardToClaude(message: string, ctx: CommandContext, opts?: {
370
417
  cwdOverride?: string;
371
418
  binding?: AgentBinding;
419
+ autoRetry?: boolean;
372
420
  }): Promise<void>;
421
+ /**
422
+ * 内容审核截断后的单次自动续跑。
423
+ *
424
+ * DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
425
+ * 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
426
+ * 于是一次(多数是误报的)判定就把整个 turn 判死。2026-10-21 现场:60 步、
427
+ * 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
428
+ * 加一句英文报文。
429
+ *
430
+ * 桥侧不改 dsh 的重试策略,改用「同一 session 补一条继续提示」:审核是概率
431
+ * 判定,重采样通常能过;模型从历史里接着做,不必重跑已完成的工作。
432
+ * 只续一次——续跑仍被截断就停在错误卡,不进入无限重试。
433
+ */
434
+ private retryAfterContentFilter;
373
435
  /**
374
436
  * Step 1 of forwardToClaude: Create run session components.
375
437
  * Returns runId, cardSession, and activeRun object for tracking.
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { sleep } from '../common/sleep.js';
3
+ import { isContentFilterFailure } from '../common/turn-failure.js';
3
4
  import { resolveFinalUsage, errorMessage } from './final-usage.js';
4
5
  import { getLogger } from '../logger/index.js';
5
6
  import { RunCardSession } from '../card/run-card-session.js';
@@ -150,6 +151,16 @@ export class Bridge {
150
151
  * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
151
152
  */
152
153
  compactableRuns = new Map();
154
+ /**
155
+ * 终态卡片的渲染快照(runId → state + options),供「详情」在 run 结束后
156
+ * 仍能二次渲染。
157
+ *
158
+ * run 结束时 activeRuns 被清空,而卡片上的「详情」按钮还活着;没有快照,
159
+ * 点终态卡上的「详情」只能得到一句「已失效」。只保留最近若干条:单条快照
160
+ * 受 MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡。
161
+ */
162
+ runCardSnapshots = new Map();
163
+ static RUN_CARD_SNAPSHOT_MAX = 8;
153
164
  /**
154
165
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
155
166
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -575,6 +586,17 @@ export class Bridge {
575
586
  return result;
576
587
  }
577
588
  async interruptCurrentRun(input) {
589
+ return (await this.interruptCurrentRunDetailed(input)).stopped;
590
+ }
591
+ /**
592
+ * Same as {@link interruptCurrentRun}, plus what the runner's stop actually
593
+ * achieved. DSH returns a {@link StopOutcome} (withdrawn / cancelled /
594
+ * unconfirmed); process runners and bash return none.
595
+ *
596
+ * Callers use `stop.status === 'unconfirmed'` to avoid promising "stopped" or
597
+ * "executing immediately" when a turn may still be running in the host.
598
+ */
599
+ async interruptCurrentRunDetailed(input) {
578
600
  // Find the active claude run matching this user/chat
579
601
  for (const [cwd, active] of this.activeRuns) {
580
602
  if (input.cwd !== undefined && cwd !== input.cwd)
@@ -595,13 +617,18 @@ export class Bridge {
595
617
  // unconditional reset would then zero the successor's count and mark
596
618
  // its slot interrupted, hiding it from queue cards.
597
619
  const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
598
- await Promise.allSettled([
620
+ const [, stopResult] = await Promise.allSettled([
599
621
  // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
600
622
  // interruptedReason,与审批超时自动取消(approval_timeout)、审批
601
623
  // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
602
624
  active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
603
625
  runner.stop({ immediate: true }),
604
626
  ]);
627
+ const stop = stopResult.status === 'fulfilled' &&
628
+ stopResult.value !== null &&
629
+ typeof stopResult.value === 'object'
630
+ ? stopResult.value
631
+ : undefined;
605
632
  // The chain may have advanced while the stop was in flight: a new run
606
633
  // (from a task that began during the stop window) may now occupy this
607
634
  // cwd. Only delete the entry we actually stopped.
@@ -641,7 +668,7 @@ export class Bridge {
641
668
  this.queueManager.resetExecutingCount(cwd, stoppedSlot);
642
669
  }
643
670
  getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
644
- return true;
671
+ return { stopped: true, agentKind: active.agentKind, ...(stop ? { stop } : {}) };
645
672
  }
646
673
  }
647
674
  // Then check active bash runs (! commands). These bypass the serial queue
@@ -658,11 +685,11 @@ export class Bridge {
658
685
  await b.cardSession.finish('interrupted', { exitCode: -1 });
659
686
  this.activeBashRuns.delete(runId);
660
687
  getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
661
- return true;
688
+ return { stopped: true };
662
689
  }
663
690
  }
664
691
  getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
665
- return false;
692
+ return { stopped: false };
666
693
  }
667
694
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
668
695
  async sendResult(result, ctx) {
@@ -756,6 +783,47 @@ export class Bridge {
756
783
  return false;
757
784
  }
758
785
  }
786
+ /**
787
+ * 终态快照的写入点:finalize 时把当前 state + 渲染选项留一份。
788
+ *
789
+ * 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
790
+ * 与 activeRuns 生命周期在同一个地方收口。
791
+ */
792
+ rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
793
+ this.runCardSnapshots.set(runId, { state, options, cwd, agentKind });
794
+ while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
795
+ const oldest = this.runCardSnapshots.keys().next().value;
796
+ if (oldest === undefined)
797
+ break;
798
+ this.runCardSnapshots.delete(oldest);
799
+ }
800
+ }
801
+ /**
802
+ * 停止终态卡上的一个后台任务。
803
+ *
804
+ * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
805
+ * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
806
+ * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
807
+ * 会留在卡片上而任务早已结束。
808
+ */
809
+ async killBackgroundJob(runId, jobId, ctx) {
810
+ const snapshot = this.runCardSnapshots.get(runId);
811
+ const sessionId = snapshot?.state.sessionId;
812
+ if (!snapshot || !sessionId)
813
+ return false;
814
+ const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
815
+ if (typeof runner.killJob !== 'function')
816
+ return false;
817
+ const killed = await runner.killJob(sessionId, jobId);
818
+ if (!killed)
819
+ return false;
820
+ snapshot.state = {
821
+ ...snapshot.state,
822
+ backgroundJobs: (snapshot.state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
823
+ };
824
+ await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
825
+ return true;
826
+ }
759
827
  /** Send a file to the user via Feishu. */
760
828
  async sendFile(filePath, ctx) {
761
829
  try {
@@ -852,6 +920,66 @@ export class Bridge {
852
920
  // Step 3: Finalize the run (error handling, fallback card, completion notification, cleanup)
853
921
  await this.finalizeRun(cardSession, activeRun, ctx, cwd);
854
922
  }
923
+ // Step 4: 服务方内容审核截断(`finish_reason: content_filter`)后自动续跑一次。
924
+ // 放在 finalize 之后:activeRuns 槽位已释放,续跑能正常开新卡片。
925
+ await this.retryAfterContentFilter({
926
+ cardSession,
927
+ ctx,
928
+ cwd,
929
+ agentKind,
930
+ sessionId,
931
+ sessionEpochAtStart,
932
+ autoRetried: opts?.autoRetry === true,
933
+ });
934
+ }
935
+ /**
936
+ * 内容审核截断后的单次自动续跑。
937
+ *
938
+ * DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
939
+ * 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
940
+ * 于是一次(多数是误报的)判定就把整个 turn 判死。2026-10-21 现场:60 步、
941
+ * 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
942
+ * 加一句英文报文。
943
+ *
944
+ * 桥侧不改 dsh 的重试策略,改用「同一 session 补一条继续提示」:审核是概率
945
+ * 判定,重采样通常能过;模型从历史里接着做,不必重跑已完成的工作。
946
+ * 只续一次——续跑仍被截断就停在错误卡,不进入无限重试。
947
+ */
948
+ async retryAfterContentFilter(params) {
949
+ const state = params.cardSession.currentState;
950
+ if (state.terminal !== 'error' || !isContentFilterFailure(state.errorMsg))
951
+ return;
952
+ if (params.autoRetried) {
953
+ getLogger().info(`[lark-remote] content_filter auto-retry already used runId=${state.runId} cwd=${params.cwd}`);
954
+ return;
955
+ }
956
+ // 会话指针在 run 期间被 /new、/resume 等移动过:续跑会接到另一个会话上,
957
+ // 「从断点继续」的语义不再成立,宁可不做(错误卡已经把话说清楚)。
958
+ const epochNow = this.sessionStore.getSessionEpoch(params.ctx.userId, params.agentKind);
959
+ if (epochNow !== params.sessionEpochAtStart) {
960
+ getLogger().info(`[lark-remote] content_filter auto-retry skipped: session epoch moved runId=${state.runId}`);
961
+ return;
962
+ }
963
+ getLogger().warn(`[lark-remote] content_filter detected, auto-retrying once runId=${state.runId} cwd=${params.cwd}`);
964
+ await this.sendResult({
965
+ text: '🔁 上一回合被服务方内容审核截断(content_filter,通常是误报),已自动重试一次。',
966
+ }, params.ctx);
967
+ try {
968
+ await this.forwardToClaude('(自动继续)上一回合因服务方内容审核被截断(content_filter,通常是误报)。' +
969
+ '请从上次中断的位置继续,不要重复已经完成的步骤。', params.ctx, {
970
+ cwdOverride: params.cwd,
971
+ binding: {
972
+ agent: params.agentKind,
973
+ sessionId: params.sessionId,
974
+ sessionEpoch: params.sessionEpochAtStart,
975
+ },
976
+ autoRetry: true,
977
+ });
978
+ }
979
+ catch (err) {
980
+ // 续跑自身失败不应冒泡成未捕获异常:用户已经收到错误卡与重试通知。
981
+ getLogger().error(`[lark-remote] content_filter auto-retry failed runId=${state.runId}`, err);
982
+ }
855
983
  }
856
984
  /**
857
985
  * Step 1 of forwardToClaude: Create run session components.
@@ -1222,7 +1350,10 @@ export class Bridge {
1222
1350
  await r.respondApproval(requestId, response);
1223
1351
  }
1224
1352
  },
1225
- interruptTurn: () => runner.stop(),
1353
+ interruptTurn: async () => {
1354
+ // DSH may return a StopOutcome; the coordinator only awaits.
1355
+ await runner.stop();
1356
+ },
1226
1357
  pushToCard: async (events) => {
1227
1358
  for (const ev of events)
1228
1359
  await cardSession.push(ev);
@@ -1409,17 +1540,19 @@ export class Bridge {
1409
1540
  try {
1410
1541
  getLogger().debug(`[lark-remote] finally settle runId=${runId} state.terminal=${cardSession.currentState.terminal}`);
1411
1542
  const settleResult = await cardSession.settle();
1543
+ // 卡片收尾后仍要能点「详情」:把终态 state 与当时的渲染选项留一份。
1544
+ // 与 fallback 卡共用同一份 options(能力门控一致,展开后按钮语义不变)。
1545
+ const finalRenderOptions = {
1546
+ agentKind: activeRun.agentKind,
1547
+ // 与 createRunSession 的 renderOptions 一致:fallback 卡也要按
1548
+ // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1549
+ // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1550
+ compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1551
+ };
1552
+ this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1412
1553
  let runCardSent = false;
1413
1554
  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);
1555
+ const sent = await this.sendResult({ card: renderRunCard(cardSession.currentState, finalRenderOptions) }, ctx);
1423
1556
  runCardSent = sent;
1424
1557
  }
1425
1558
  else {
@@ -1,4 +1,5 @@
1
1
  import { getLogger } from '../logger/index.js';
2
+ import { cardWithHeader } from '../card/card-shared.js';
2
3
  import { displayName } from '../platform/path.js';
3
4
  /**
4
5
  * Per-workspace cap on the number of tasks WAITING to execute.
@@ -171,25 +172,19 @@ export class QueueManager {
171
172
  // a rejected task never executes, so it must not occupy a slot.
172
173
  if (currentQueueLength >= MAX_WAITING_QUEUE) {
173
174
  getLogger().warn(`[queue-manager] queue full cwd=${cwd} depth=${currentQueueLength} rejecting task messageId=${taskMeta.messageId}`);
174
- void this.sendCard(taskMeta.chatId, {
175
- schema: '2.0',
176
- config: { wide_screen_mode: true },
177
- header: {
178
- template: 'red',
179
- title: { tag: 'plain_text', content: '🚫 队列已满' },
180
- },
181
- body: {
182
- elements: [
183
- {
184
- tag: 'div',
185
- text: {
186
- tag: 'lark_md',
187
- content: `当前工作目录已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
188
- },
175
+ void this.sendCard(taskMeta.chatId, cardWithHeader({
176
+ template: 'red',
177
+ title: '🚫 队列已满',
178
+ elements: [
179
+ {
180
+ tag: 'div',
181
+ text: {
182
+ tag: 'lark_md',
183
+ content: `当前工作目录已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
189
184
  },
190
- ],
191
- },
192
- }, { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
185
+ },
186
+ ],
187
+ }), { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
193
188
  getLogger().error('[queue-manager] failed to send queue-full rejection card:', err);
194
189
  });
195
190
  return;
@@ -559,26 +554,20 @@ export class QueueManager {
559
554
  return;
560
555
  }
561
556
  const previewForCard = liveTask?.messagePreview ?? messagePreview;
562
- const card = {
563
- schema: '2.0',
564
- config: { wide_screen_mode: true },
565
- header: {
566
- template: 'green',
567
- title: { tag: 'plain_text', content: '▶️ 已开始执行' },
568
- },
569
- body: {
570
- elements: [
571
- {
572
- tag: 'div',
573
- text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
574
- },
575
- { tag: 'hr' },
576
- { tag: 'div', text: { tag: 'lark_md', content: `📝 \`${previewForCard}\`` } },
577
- { tag: 'hr' },
578
- ...this.buildQueueActionButtons(cwd, messageId, true),
579
- ],
580
- },
581
- };
557
+ const card = cardWithHeader({
558
+ template: 'green',
559
+ title: '▶️ 已开始执行',
560
+ elements: [
561
+ {
562
+ tag: 'div',
563
+ text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
564
+ },
565
+ { tag: 'hr' },
566
+ { tag: 'div', text: { tag: 'lark_md', content: `📝 \`${previewForCard}\`` } },
567
+ { tag: 'hr' },
568
+ ...this.buildQueueActionButtons(cwd, messageId, true),
569
+ ],
570
+ });
582
571
  await this.updateCard(cardMessageId, card);
583
572
  getLogger().debug(`[queue-manager] queue card updated to executing messageId=${messageId}`);
584
573
  }
@@ -622,26 +611,20 @@ export class QueueManager {
622
611
  return;
623
612
  }
624
613
  const workspaceName = displayName(cwd);
625
- const card = {
626
- schema: '2.0',
627
- config: { wide_screen_mode: true },
628
- header: {
629
- template: 'gray',
630
- title: { tag: 'plain_text', content: '❌ 已撤销' },
631
- },
632
- body: {
633
- elements: [
634
- {
635
- tag: 'div',
636
- text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
637
- },
638
- { tag: 'hr' },
639
- { tag: 'div', text: { tag: 'lark_md', content: `📝 该消息已从队列中撤销` } },
640
- { tag: 'hr' },
641
- ...this.buildQueueActionButtons(cwd, messageId, true),
642
- ],
643
- },
644
- };
614
+ const card = cardWithHeader({
615
+ template: 'gray',
616
+ title: '❌ 已撤销',
617
+ elements: [
618
+ {
619
+ tag: 'div',
620
+ text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
621
+ },
622
+ { tag: 'hr' },
623
+ { tag: 'div', text: { tag: 'lark_md', content: `📝 该消息已从队列中撤销` } },
624
+ { tag: 'hr' },
625
+ ...this.buildQueueActionButtons(cwd, messageId, true),
626
+ ],
627
+ });
645
628
  try {
646
629
  await this.updateCard(cardMessageId, card);
647
630
  getLogger().debug(`[queue-manager] queue card updated to cancelled, messageId=${messageId}`);
@@ -5,7 +5,8 @@
5
5
  * Uses CardKit 2.0 (no tag:"action" container — 200861 rule).
6
6
  */
7
7
  import { collapsibleMarkdownPanel } from './collapsible.js';
8
- import { truncateUtf8, truncateMarkdownTables } from './text-truncate.js';
8
+ import { truncateMarkdownTables } from './text-truncate.js';
9
+ import { truncateUtf8 } from '../common/truncate.js';
9
10
  import { cardButton } from './card-shared.js';
10
11
  /** Unique nonce for card button callbacks (SDK dedup + router validation). */
11
12
  function mkNonce() {
@@ -38,7 +38,6 @@ export declare class BashCardSession extends CardSession<BashState, BashRenderOp
38
38
  protected get logPrefix(): string;
39
39
  protected get errorPrefix(): string;
40
40
  protected renderCard(state: BashState, options: BashRenderOptions): object;
41
- protected hasOwnBudgetProtection(): boolean;
42
41
  protected isFlushableState(): boolean;
43
42
  /** Patch the card with updated bash state (e.g. new stdout/stderr). */
44
43
  update(patch: Partial<BashState>): Promise<void>;
@@ -53,14 +53,6 @@ export class BashCardSession extends CardSession {
53
53
  renderCard(state, options) {
54
54
  return renderBashCard(state, options);
55
55
  }
56
- hasOwnBudgetProtection() {
57
- // renderBashCard 自带 stringify 级 degraded/extreme fallback 预算保护
58
- // (与 renderRunCard 对称),保证返回卡 ≤28KB 且保留 command/exitCode。
59
- // enforceCardBudget 是会话事件面板专用的静态裁剪层,对 bash 卡的普通
60
- // markdownDiv output 是 misfit(超限时极端降级丢 command/exitCode),bash
61
- // 卡应跳过它,靠自身 renderer 保护。
62
- return true;
63
- }
64
56
  isFlushableState() {
65
57
  // finish() 已把 state 转终态后,in-flight/延迟 flush 不再用旧 state patch。
66
58
  return this.state.terminal === 'running';
@@ -1,6 +1,7 @@
1
1
  import { terminalToColor, terminalToLabel, stopButton } from './card-shared.js';
2
2
  import { markdownDiv } from './collapsible.js';
3
- import { truncateUtf8, CARD_BUDGET_BYTES } from './text-truncate.js';
3
+ import { CARD_BUDGET_BYTES } from './text-truncate.js';
4
+ import { truncateUtf8 } from '../common/truncate.js';
4
5
  /**
5
6
  * 飞书卡片大小限制(安全阈值,与 run-renderer 一致)。
6
7
  * 从共享的 text-truncate.ts 导入,避免三处各自定义后数值漂移。