lark-remote 0.4.1 → 0.4.3

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 (86) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/approval-coordinator.d.ts +2 -0
  5. package/dist/bridge/approval-coordinator.js +16 -7
  6. package/dist/bridge/index.d.ts +22 -0
  7. package/dist/bridge/index.js +138 -56
  8. package/dist/card/approval-render.js +9 -8
  9. package/dist/card/card-budget.js +39 -26
  10. package/dist/card/run-renderer.js +9 -2
  11. package/dist/card/text-truncate.d.ts +9 -10
  12. package/dist/card/text-truncate.js +48 -43
  13. package/dist/cli.js +17 -0
  14. package/dist/config/codex-config.d.ts +3 -0
  15. package/dist/config/codex-config.js +4 -1
  16. package/dist/config/dir.js +6 -6
  17. package/dist/config/index.d.ts +7 -5
  18. package/dist/config/index.js +52 -13
  19. package/dist/config/kimi-config.d.ts +4 -1
  20. package/dist/config/kimi-config.js +5 -2
  21. package/dist/config/pi-config.d.ts +2 -7
  22. package/dist/config/pi-config.js +6 -6
  23. package/dist/config/wizard.js +8 -4
  24. package/dist/connector/index.d.ts +19 -0
  25. package/dist/connector/index.js +134 -23
  26. package/dist/inbound/placeholder.d.ts +3 -2
  27. package/dist/inbound/placeholder.js +17 -7
  28. package/dist/index.js +28 -6
  29. package/dist/instance-lock.d.ts +31 -15
  30. package/dist/instance-lock.js +64 -62
  31. package/dist/platform/agent-stopper.d.ts +29 -7
  32. package/dist/platform/agent-stopper.js +40 -8
  33. package/dist/platform/identity.d.ts +59 -3
  34. package/dist/platform/identity.js +105 -30
  35. package/dist/platform/path.d.ts +0 -7
  36. package/dist/platform/path.js +1 -51
  37. package/dist/platform/shell.d.ts +14 -0
  38. package/dist/platform/shell.js +35 -2
  39. package/dist/platform/sleep-blocker.d.ts +6 -0
  40. package/dist/platform/sleep-blocker.js +31 -5
  41. package/dist/platform/spawn.d.ts +1 -1
  42. package/dist/platform/spawn.js +1 -1
  43. package/dist/platform/terminator-posix.d.ts +1 -29
  44. package/dist/platform/terminator-posix.js +32 -30
  45. package/dist/platform/terminator-win32.js +4 -2
  46. package/dist/platform/terminator.d.ts +2 -2
  47. package/dist/platform/terminator.js +17 -2
  48. package/dist/platform/types.d.ts +3 -0
  49. package/dist/router/card-action-feedback.d.ts +17 -0
  50. package/dist/router/card-action-feedback.js +31 -0
  51. package/dist/router/card-helpers.d.ts +38 -2
  52. package/dist/router/card-helpers.js +79 -2
  53. package/dist/router/config/claude.js +8 -5
  54. package/dist/router/index.d.ts +41 -6
  55. package/dist/router/index.js +254 -84
  56. package/dist/runner/claude/runner.js +1 -1
  57. package/dist/runner/claude/session.d.ts +2 -6
  58. package/dist/runner/claude/session.js +7 -8
  59. package/dist/runner/codex/app-server/protocol-types.d.ts +4 -0
  60. package/dist/runner/codex/app-server/protocol-types.js +4 -0
  61. package/dist/runner/codex/app-server/runner.js +3 -0
  62. package/dist/runner/common/acp/base-acp-runner.js +7 -0
  63. package/dist/runner/common/connection-based-runner.d.ts +18 -0
  64. package/dist/runner/common/connection-based-runner.js +31 -0
  65. package/dist/runner/common/jsonrpc/client.js +6 -1
  66. package/dist/runner/common/jsonrpc/connection-manager.d.ts +20 -0
  67. package/dist/runner/common/jsonrpc/connection-manager.js +27 -1
  68. package/dist/runner/common/jsonrpc/transport.d.ts +29 -1
  69. package/dist/runner/common/jsonrpc/transport.js +79 -10
  70. package/dist/runner/common/spawning-runner.d.ts +49 -19
  71. package/dist/runner/common/spawning-runner.js +94 -54
  72. package/dist/runner/dsh/client.d.ts +1 -3
  73. package/dist/runner/dsh/client.js +0 -4
  74. package/dist/runner/dsh/types.d.ts +0 -36
  75. package/dist/runner/kimi/acp/runner.d.ts +18 -1
  76. package/dist/runner/kimi/acp/runner.js +100 -42
  77. package/dist/runner/kimi/acp/translator.d.ts +0 -1
  78. package/dist/runner/opencode/acp/translator.d.ts +0 -1
  79. package/dist/runner/pi/rpc/protocol-types.d.ts +4 -0
  80. package/dist/runner/pi/rpc/runner.js +3 -0
  81. package/dist/runner/resolve-agent-choices.js +0 -5
  82. package/package.json +2 -2
  83. package/dist/runner/common/process-stopper.d.ts +0 -7
  84. package/dist/runner/common/process-stopper.js +0 -8
  85. package/dist/runner/dsh/fake-dsh-server.d.ts +0 -60
  86. package/dist/runner/dsh/fake-dsh-server.js +0 -156
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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.4.3] - 2026-09-24
9
+
10
+ ### 新增
11
+
12
+ - **富文本消息的顶层附件也能落盘了**:`@larksuite/channel` 从 `0.3.0` 升到 `0.7.1`。富文本(`post`)消息在**正文之外**的顶层附件区(`<file key=… name=…/>`,文件夹为 `<folder …/>`)此前不产出资源、会被当噪声丢掉;现在按序号识别并保存到当前工作目录,附件绝对路径照常写进 agent 提示词。往群里发一条「正文 + PDF」的富文本消息,agent 拿到的和普通文件消息一致(文件夹不产资源,只保留正文)
13
+ - **Windows 上收摊改走协议通道**:进程终止不再依赖跨进程 `SIGTERM`(win32 上本就不可靠),各 runner 统一登记「协议停止请求」,由 agent 自己响应后退出
14
+
15
+ ### 修复
16
+
17
+ - **审批重投不再永不过期**:同一个 `requestId` 被重复投递时重新计时,并保证一个 requestId 最多只有一条待触发计时器。此前重投会留下两条计时器——要么审批永远等不到超时(server 无限等待),要么旧计时器提前把新窗口咬掉
18
+ - **Windows 防休眠此前从未生效**:`SetThreadExecutionState` 的 flags 因 Int32 溢出被截断,脚本一直静默失败。现按无符号 32 位、以十进制字面量传参,并把非终止错误升级为终止错误,让失败能落到告警分支而不是无声无息
19
+ - **资源下载超时不再静默**:超时只停止等待,底层下载照跑,晚到的结果由回调补做清理——避免「界面说超时、文件其实落了盘却没人管」
20
+ - **租户 token 失效会自动恢复**:识别 `tenant_access_token` 失效(飞书业务码 `99991663` 与 HTTP 401)并刷新后重试,不再让整条消息处理链失败
21
+ - **卡片 patch 失败不再成为无人接的 rejection**:流式卡片的 patch 由 throttle 触发、脱离 await 链(`Throttle.fireSoon` 丢掉 promise),业务码异常时改为记 warn 并原样透传返回值;需要抛出的场景收敛到带 await 的更新入口
22
+
23
+ ### 变更
24
+
25
+ - **平台能力收口**:进程终止统一走 `Terminator` 抽象(删除 `ProcessStopper` 兼容壳),路径、进程身份、休眠能力按平台各自实现
26
+ - **claude 权限模式取值口径修正**:`permissionMode` 的 `default` 表示**省略该参数**(不是 CLI 的取值),与其他 agent 的配置语义对齐
27
+ - **测试套件平台无关化**:消除用例与 fixture 对宿主平台的隐性依赖(Windows 上全量失败从 25 条降到 0);同步 CLI 探测通道短路,不再依赖本机装了哪些 agent CLI
28
+ - **测试临时目录统一自清**:文件级钩子 + 全局兜底清扫,消除 `%TEMP%` 泄漏,被强杀或超时的场景也能收拾干净
29
+
30
+ ## [0.4.2] - 2026-09-19
31
+
32
+ ### 新增
33
+
34
+ - **`/ws` `/ls` 列表卡关键词搜索**:两张列表卡各多出一行搜索输入框,输入关键词后**按回车提交**即可筛选。`/ws` 匹配别名或完整路径,`/ls` 只匹配**当前层**条目名(不递归子目录),均大小写不敏感、子串包含;计数与页码一律按过滤后的结果计算。筛选词不落服务端(只随卡片回调 payload 传递,且仅非空时注入),服务端保持零状态,未筛选时的卡片形态与旧版完全一致。筛选态下翻页/刷新/排序/使用/删除会保留关键词,进入其他目录或切换工作目录则清除筛选(换目录后旧关键词没有意义)
35
+
36
+ ### 修复
37
+
38
+ - **卡片提交指引不再写「点 ✓」**:飞书移动端的 `input` 组件没有提交图标,只有键盘的回车/完成键(真机反馈)。所有用户可见文案统一为「按回车提交」,包括分页页码跳转、别名编辑、指令文本编辑与审批卡的修改意见
39
+ - **取消筛选改走显式按钮**:「清空输入再回车」在部分客户端不会触发回调(官方文档未承诺空串会发回调),实测清空后回车没有反应。现在筛选态下搜索行右侧会出现**「清除筛选」**按钮,走显式标记强制清除,不再依赖「读不到输入值」这一隐式条件
40
+ - **别名编辑卡不再把「留空提交」当删除入口**:同一处隐式条件问题,删除别名统一走列表卡别名旁的 ✕
41
+
42
+ ### 变更
43
+
44
+ - 卡片布局文档(中英)补齐 CardKit `input` 的三条约束:没有逐键回调所以「边输边过滤」做不到、文案不写「点 ✓」、取消语义要有显式控件;`design.md` 中「input 提交图标」的表述改为「input 提交回传」(图标只是桌面端的一种触发方式)
45
+ - 英文文档补齐 `/ws` `/ls` 关键词搜索:`README.en.md` 与 `docs/en/usage.md` 的命令表和功能段跟上中文口径
46
+
8
47
  ## [0.4.1] - 2026-09-18
9
48
 
10
49
  ### 新增
package/README.en.md CHANGED
@@ -108,9 +108,9 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
108
108
  |---------|-------|----------|
109
109
  | `/help` | `/h` | Command list |
110
110
  | `/cd <path>` | - | Switch agent working directory (`~`, absolute/relative; clears the session) |
111
- | `/ls [dir\|file]` | - | Directory/file card; click a directory to browse, click a file ≤30MB to send it to Feishu; paginates beyond 30 entries with a page-number jump input. Passing a file path lists that file itself |
111
+ | `/ls [dir\|file]` | - | Directory/file card; click a directory to browse, click a file ≤30MB to send it to Feishu; paginates beyond 30 entries with a page-number jump input, and current-level entries can be filtered by keyword. Passing a file path lists that file itself |
112
112
  | `/download <path>` | `/d` | Send a local file straight to the chat (`~`, absolute/relative paths, 30MB cap) |
113
- | `/ws save\|use\|remove` | - | Named directory aliases (`/ws` lists by default) |
113
+ | `/ws save\|use\|remove` | - | Named directory aliases (`/ws` lists by default, filterable by alias name or path keyword) |
114
114
  | `/resume [agent] [N\|id]` | `/r` | List/switch agent sessions for the current directory (card) |
115
115
  | `/active` | - | List all running sessions |
116
116
  | `/new` | - | Clear the current session (keeps cwd) |
package/README.md CHANGED
@@ -108,9 +108,9 @@ lark-remote 启动后不在终端输出,运行日志写入 `~/.lark-remote/log
108
108
  |------|------|------|
109
109
  | `/help` | `/h` | 命令列表 |
110
110
  | `/cd <path>` | - | 切换 agent 工作目录(支持 `~`、绝对/相对路径,清空会话) |
111
- | `/ls [dir\|file]` | - | 弹出目录/文件卡片;点击目录切换,点击 30MB 内文件发送到飞书;条目 >30 时支持翻页与直接输入页码跳转。传文件路径时直接列出该文件(可下载) |
111
+ | `/ls [dir\|file]` | - | 弹出目录/文件卡片;点击目录切换,点击 30MB 内文件发送到飞书;条目 >30 时支持翻页与直接输入页码跳转,卡片内可按关键词筛选本层条目。传文件路径时直接列出该文件(可下载) |
112
112
  | `/download <path>` | `/d` | 直接下载(发送)指定文件到飞书(支持 `~`、绝对/相对路径,上限 30MB) |
113
- | `/ws save\|use\|remove` | - | 命名目录别名管理(`/ws` 默认列出) |
113
+ | `/ws save\|use\|remove` | - | 命名目录别名管理(`/ws` 默认列出,可按名称/路径关键词筛选) |
114
114
  | `/resume [agent] [N\|id]` | `/r` | 列出/切换当前目录的 agent session(卡片) |
115
115
  | `/active` | - | 列出所有正在进行中的 session |
116
116
  | `/new` | - | 清空当前会话(保留工作目录) |
@@ -142,6 +142,8 @@ export declare class ApprovalCoordinator {
142
142
  */
143
143
  pendingCount(): number;
144
144
  private clearTimer;
145
+ /** 装超时计时器;先清掉旧的,保证一个 requestId 最多只有一条待触发的计时。 */
146
+ private armTimeout;
145
147
  /** nonce 去重:同一 nonce 只允许生效一次(防连点/飞书重投递)。 */
146
148
  private assertFreshNonce;
147
149
  /** 记录问题答案到 tracked.answers(懒初始化 Map)。 */
@@ -30,28 +30,30 @@ export class ApprovalCoordinator {
30
30
  * Handle an approval_requested event.
31
31
  */
32
32
  onRequested(event) {
33
+ // 事件级 timeoutMs 优先(Codex autoResolutionMs / Pi extension timeout),
34
+ // 缺省回落 run 级默认超时。放在事件上而非桥按 agentKind 分支,公共层
35
+ // 保持无 agent 分支。
36
+ const timeoutMs = event.timeoutMs ?? this.approvalTimeoutMs;
33
37
  const existing = this.approvals.get(event.requestId);
34
38
  if (existing) {
35
39
  // Already tracked — update state
36
40
  existing.state = 'pending';
37
41
  existing.view = event.view;
42
+ // 重投必须重新计时:上一路的计时器在 resolved/expired 时已清掉,不重装
43
+ // 这条审批就永不过期,server 只能无限等待(红线「审批超时自动 cancel」)。
44
+ this.armTimeout(existing, timeoutMs);
38
45
  return;
39
46
  }
40
- // 事件级 timeoutMs 优先(Codex autoResolutionMs / Pi extension timeout),
41
- // 缺省回落 run 级默认超时。放在事件上而非桥按 agentKind 分支,公共层
42
- // 保持无 agent 分支。
43
- const timer = setTimeout(() => {
44
- this.expireApproval(event.requestId);
45
- }, event.timeoutMs ?? this.approvalTimeoutMs);
46
47
  const tracked = {
47
48
  requestId: event.requestId,
48
49
  kind: event.kind,
49
50
  state: 'pending',
50
51
  view: event.view,
51
52
  createdAt: Date.now(),
52
- timeoutTimer: timer,
53
+ timeoutTimer: null,
53
54
  };
54
55
  this.approvals.set(event.requestId, tracked);
56
+ this.armTimeout(tracked, timeoutMs);
55
57
  }
56
58
  /**
57
59
  * Handle an approval_resolved event.
@@ -329,6 +331,13 @@ export class ApprovalCoordinator {
329
331
  tracked.timeoutTimer = null;
330
332
  }
331
333
  }
334
+ /** 装超时计时器;先清掉旧的,保证一个 requestId 最多只有一条待触发的计时。 */
335
+ armTimeout(tracked, timeoutMs) {
336
+ this.clearTimer(tracked);
337
+ tracked.timeoutTimer = setTimeout(() => {
338
+ this.expireApproval(tracked.requestId);
339
+ }, timeoutMs);
340
+ }
332
341
  /** nonce 去重:同一 nonce 只允许生效一次(防连点/飞书重投递)。 */
333
342
  assertFreshNonce(requestId, nonce) {
334
343
  if (nonce === undefined)
@@ -33,6 +33,12 @@ interface BridgeContext {
33
33
  userId: string;
34
34
  chatId: string;
35
35
  messageId: string;
36
+ /**
37
+ * 本轮(装配窗口)全部入站 messageId,`messageId` 是其中最后一条。
38
+ * 只有走入站装配器的路径会填;卡片动作等单消息路径留空,收尾退化为只处理
39
+ * `messageId`。缺它会导致合并轮次里先到的消息挂着 Typing 不撤(§B8)。
40
+ */
41
+ turnMessageIds?: string[];
36
42
  }
37
43
  /** A sendable result payload (mirror of the router's CommandResult shape). */
38
44
  interface BridgeResult {
@@ -347,11 +353,27 @@ export declare class Bridge {
347
353
  * Returns runId, cardSession, and activeRun object for tracking.
348
354
  */
349
355
  private createRunSession;
356
+ /**
357
+ * §9.12 空闲看门狗:单 interval + 截止时间判定(P2-1),普通 run 与 compact
358
+ * 共用同一套兜底。事件到达只 `touch()` 刷新时间戳(无 timer 操作);静默超过
359
+ * `idleTimeoutMs` 才 finish('idle_timeout') + runner.stop(),让该工作区的串行
360
+ * 队列继续推进(否则一个挂死的 run 永久堵住后续消息)。
361
+ */
362
+ private startIdleWatchdog;
350
363
  /**
351
364
  * Step 2 of forwardToClaude: Run the agent and consume its event stream.
352
365
  * Handles idle watchdog, event processing, session sync, and state transitions.
353
366
  */
354
367
  private runAgentStreamToEnd;
368
+ /**
369
+ * 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
370
+ *
371
+ * 逐条挂 Typing(`src/index.ts` 对每条入站消息都加),但 ctx.messageId 只是
372
+ * 装配窗口里的最后一条——只对它收尾会让先到的那几条永远停在「正在输入」。
373
+ * connector 的两个 reaction 方法自带 try/catch(缺失 reaction 是 no-op),
374
+ * 所以这里 fire-and-forget 即可,不额外兜错。
375
+ */
376
+ private finishTurnReactions;
355
377
  /**
356
378
  * Step 3 of forwardToClaude: Finalize the run.
357
379
  * Handles fallback card sending, completion notification, and cleanup.
@@ -782,6 +782,51 @@ export class Bridge {
782
782
  getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${runId}`);
783
783
  return { runId, cardSession, activeRun };
784
784
  }
785
+ /**
786
+ * §9.12 空闲看门狗:单 interval + 截止时间判定(P2-1),普通 run 与 compact
787
+ * 共用同一套兜底。事件到达只 `touch()` 刷新时间戳(无 timer 操作);静默超过
788
+ * `idleTimeoutMs` 才 finish('idle_timeout') + runner.stop(),让该工作区的串行
789
+ * 队列继续推进(否则一个挂死的 run 永久堵住后续消息)。
790
+ */
791
+ startIdleWatchdog(opts) {
792
+ let lastEventTs = Date.now();
793
+ let interval = null;
794
+ const stop = () => {
795
+ if (interval)
796
+ clearInterval(interval);
797
+ interval = null;
798
+ };
799
+ if (this.idleTimeoutMs > 0) {
800
+ // 周期取 idleTimeoutMs/2:停滞后最多在 1.5× idleTimeoutMs 内触发
801
+ // (半个窗口延迟,看门狗语义可接受)
802
+ const tickMs = Math.max(1, Math.floor(this.idleTimeoutMs / 2));
803
+ interval = setInterval(() => {
804
+ if (Date.now() - lastEventTs <= this.idleTimeoutMs)
805
+ return;
806
+ // 权限等待期间暂停空闲看门狗:claude/codex 等待人工决策时无 stdout
807
+ // 事件。审批由 ApprovalCoordinator 的超时(默认 5 分钟 < 看门狗窗口)
808
+ // 自愈:cancel 送达 + 中断 turn,不会无限挂起。
809
+ if ((opts.pendingApprovals?.() ?? 0) > 0) {
810
+ lastEventTs = Date.now();
811
+ return;
812
+ }
813
+ stop();
814
+ getLogger().warn(`[lark-remote] claude idle timeout, stopping process runId=${opts.runId}`);
815
+ void Promise.allSettled([
816
+ opts.session.finish('idle_timeout', {
817
+ idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
818
+ }),
819
+ opts.runner.stop(),
820
+ ]);
821
+ }, tickMs);
822
+ }
823
+ return {
824
+ touch: () => {
825
+ lastEventTs = Date.now();
826
+ },
827
+ stop,
828
+ };
829
+ }
785
830
  /**
786
831
  * Step 2 of forwardToClaude: Run the agent and consume its event stream.
787
832
  * Handles idle watchdog, event processing, session sync, and state transitions.
@@ -790,13 +835,8 @@ export class Bridge {
790
835
  // Runner 声明的 usage 权威来源(review P3-7):codex app-server = 'live'
791
836
  // (turn/started 的 tokenUsage.last 是本 turn 增量)。其余 runner 未声明。
792
837
  const usageAuthority = runner.getUsageAuthority?.();
793
- // P2-1: idle watchdog 用单 interval + lastEventTs 截止时间判定,而非每事件
794
- // clearTimeout + setTimeout 重建 timer。高频事件流下 timer 重建次数与事件数
795
- // 成正比(N 事件 = N 次 setTimeout + N 个闭包/秒);interval 方案只建一个
796
- // interval,事件到来时仅刷新时间戳(无 timer 操作)。语义不变:事件流停滞
797
- // 超过 idleTimeoutMs 仍触发 runner.stop()(§9.12)。
798
- let lastEventTs = Date.now();
799
- let idleInterval = null;
838
+ // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
839
+ let watchdog = null;
800
840
  let sawResult = false;
801
841
  // 记录 result 是否为 error: error 时跳过 jsonl usage 读取 (见 stream end 处说明)
802
842
  let resultNotSuccess = false;
@@ -867,44 +907,15 @@ export class Bridge {
867
907
  // Card session did not enter running state - still try to send static fallback
868
908
  getLogger().info(`[lark-remote] cardSession not running, using static fallback`);
869
909
  }
870
- // P2-1: 单 interval 看门狗。事件到来时只刷新 lastEventTs(无 timer 操作);
871
- // interval 周期检查 `now - lastEventTs > idleTimeoutMs`,超时才触发 stop。
872
- // 这样高频事件流下 timer 重建次数与事件数解耦(只建一个 interval),而
873
- // "15min 无事件即 stop" 的语义不变(§9.12)。周期取 idleTimeoutMs/2,使停滞
874
- // 后最多在 1.5× idleTimeoutMs 内触发(半个窗口延迟,watchdog 语义可接受)。
875
- const fireIdleTimeout = () => {
876
- // 权限等待期间暂停空闲看门狗:claude/codex 等待人工决策时无 stdout
877
- // 事件,「等审批」不是「挂死」。审批由 ApprovalCoordinator 的超时
878
- // (默认 5 分钟 < 看门狗窗口)自愈:cancel 送达 + 中断 turn,不会无限
879
- // 挂起。
880
- const pendingApproval = this.approvalCoordinators.get(runId)?.pendingCount() ?? 0;
881
- if (pendingApproval > 0) {
882
- lastEventTs = Date.now();
883
- return;
884
- }
885
- if (idleInterval)
886
- clearInterval(idleInterval);
887
- idleInterval = null;
888
- getLogger().warn(`[lark-remote] claude idle timeout, stopping process runId=${runId}`);
889
- void Promise.allSettled([
890
- cardSession.finish('idle_timeout', {
891
- idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
892
- }),
893
- runner.stop(),
894
- ]);
895
- };
896
- const resetIdle = () => {
897
- lastEventTs = Date.now();
898
- };
899
- if (this.idleTimeoutMs > 0) {
900
- const tickMs = Math.max(1, Math.floor(this.idleTimeoutMs / 2));
901
- idleInterval = setInterval(() => {
902
- if (Date.now() - lastEventTs > this.idleTimeoutMs) {
903
- fireIdleTimeout();
904
- }
905
- }, tickMs);
906
- }
907
- resetIdle();
910
+ // P2-1: 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
911
+ // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
912
+ // 武装点在建卡之后,与原实现一致(宽限期不含 cardSession.start() 耗时)。
913
+ watchdog = this.startIdleWatchdog({
914
+ runId,
915
+ session: cardSession,
916
+ runner,
917
+ pendingApprovals: () => this.approvalCoordinators.get(runId)?.pendingCount() ?? 0,
918
+ });
908
919
  let contextLength;
909
920
  getLogger().info(`[lark-remote] runner.run() begin runId=${runId} message=${message.slice(0, 100)}`);
910
921
  // Extract agent-specific options from config
@@ -995,7 +1006,7 @@ export class Bridge {
995
1006
  if (preInitResult && usageAuthority !== 'live') {
996
1007
  getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
997
1008
  // Still reset idle timer — the CLI is alive and producing events.
998
- resetIdle();
1009
+ watchdog?.touch();
999
1010
  // Do NOT push to cardSession: the run-state reducer would also
1000
1011
  // skip it (sessionId === undefined), but skipping at the bridge
1001
1012
  // layer avoids the push + render overhead for a no-op event.
@@ -1107,17 +1118,14 @@ export class Bridge {
1107
1118
  // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
1108
1119
  // 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
1109
1120
  // idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
1110
- resetIdle();
1121
+ watchdog?.touch();
1111
1122
  await cardSession.push(event);
1112
1123
  }
1113
1124
  getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${sawResult}`);
1114
1125
  // §9.12 红线(§P1-2 方案 A 第 2 点):for-await 自然结束 = CLI 已退出,
1115
1126
  // 看门狗使命结束,立即摘除 interval(不再等 finally),堵掉「stream 已结束、
1116
1127
  // 收尾处理跨过死线被误标 idle_timeout」的同源小窗口(P2-4 随本修复一并关闭)。
1117
- if (idleInterval) {
1118
- clearInterval(idleInterval);
1119
- idleInterval = null;
1120
- }
1128
+ watchdog?.stop();
1121
1129
  // stream 结束 = CLI 退出 = jsonl 已落盘。读 jsonl 拿准确的 contextLength + compactCount:
1122
1130
  // live stream-json 不发 compact_boundary,live contextLength 会被 result 的 input+output 兜底失真。
1123
1131
  // 但 result=error 时跳过: 进程可能立即退出未写新 usage (如 kimi 0.26+ 拒绝 -p+--auto
@@ -1222,8 +1230,7 @@ export class Bridge {
1222
1230
  }
1223
1231
  }
1224
1232
  finally {
1225
- if (idleInterval)
1226
- clearInterval(idleInterval);
1233
+ watchdog?.stop();
1227
1234
  const coordinator = this.approvalCoordinators.get(runId);
1228
1235
  if (coordinator) {
1229
1236
  coordinator.onTurnEnded();
@@ -1231,6 +1238,24 @@ export class Bridge {
1231
1238
  }
1232
1239
  }
1233
1240
  }
1241
+ /**
1242
+ * 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
1243
+ *
1244
+ * 逐条挂 Typing(`src/index.ts` 对每条入站消息都加),但 ctx.messageId 只是
1245
+ * 装配窗口里的最后一条——只对它收尾会让先到的那几条永远停在「正在输入」。
1246
+ * connector 的两个 reaction 方法自带 try/catch(缺失 reaction 是 no-op),
1247
+ * 所以这里 fire-and-forget 即可,不额外兜错。
1248
+ */
1249
+ finishTurnReactions(ctx, emoji) {
1250
+ const ids = ctx.turnMessageIds?.length
1251
+ ? [...new Set([...ctx.turnMessageIds, ctx.messageId])]
1252
+ : [ctx.messageId];
1253
+ // 两类 emoji 互不相干(撤 Typing / 打终态),并发发出没有顺序依赖。
1254
+ for (const id of ids) {
1255
+ void this.connector.removeReactionByEmoji(id, 'Typing');
1256
+ void this.connector.addReaction(id, emoji);
1257
+ }
1258
+ }
1234
1259
  /**
1235
1260
  * Step 3 of forwardToClaude: Finalize the run.
1236
1261
  * Handles fallback card sending, completion notification, and cleanup.
@@ -1264,7 +1289,7 @@ export class Bridge {
1264
1289
  await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
1265
1290
  }
1266
1291
  // Add a terminal-state emoji reaction to the user's original message
1267
- void this.connector.addReaction(ctx.messageId, terminalReactionEmoji(cardSession.currentState.terminal));
1292
+ this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1268
1293
  }
1269
1294
  finally {
1270
1295
  // P1-13: cleanup must survive mid-finalize errors. The only unguarded
@@ -1548,6 +1573,14 @@ export class Bridge {
1548
1573
  await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1549
1574
  return;
1550
1575
  }
1576
+ // activeRuns 以 cwd 为键:注册前先确认槽位空闲,否则压缩会覆盖在跑的 run 的
1577
+ // 记录,令 /stop 只停到最后注册的那个。
1578
+ const busyWith = this.activeRuns.get(cwd);
1579
+ if (busyWith) {
1580
+ log.info(`[lark-remote] compact refused: workspace busy cwd=${cwd} runId=${busyWith.runId}`);
1581
+ await this.sendResult({ text: '⚠️ 该工作区正在运行任务,请等待结束或先 /stop' }, ctx);
1582
+ return;
1583
+ }
1551
1584
  await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1552
1585
  const compactRunId = randomUUID();
1553
1586
  const cardSession = new RunCardSession({
@@ -1559,8 +1592,28 @@ export class Bridge {
1559
1592
  agentKind,
1560
1593
  },
1561
1594
  });
1595
+ // A6:压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
1596
+ // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
1597
+ // 压缩都只能等 runner 级超时,用户既停不掉也看不出它还在跑。
1598
+ const activeRun = {
1599
+ runId: compactRunId,
1600
+ userId: ctx.userId,
1601
+ chatId: ctx.chatId,
1602
+ session: cardSession,
1603
+ cwd,
1604
+ runner,
1605
+ agentKind,
1606
+ };
1607
+ this.activeRuns.set(cwd, activeRun);
1608
+ getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${compactRunId} compact=true`);
1609
+ let watchdog = null;
1562
1610
  try {
1563
1611
  await cardSession.start();
1612
+ watchdog = this.startIdleWatchdog({
1613
+ runId: compactRunId,
1614
+ session: cardSession,
1615
+ runner,
1616
+ });
1564
1617
  // operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
1565
1618
  await cardSession.push({
1566
1619
  type: 'turn_started',
@@ -1572,11 +1625,21 @@ export class Bridge {
1572
1625
  let resultSubtype;
1573
1626
  let resultError;
1574
1627
  for await (const event of runner.runCompact('', { cwd, sessionId })) {
1628
+ // 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
1629
+ // 否则迟到的 result 会把终态卡改写成「已完成」。
1630
+ const cardTerminal = cardSession.currentState.terminal;
1631
+ if (cardTerminal === 'done' ||
1632
+ cardTerminal === 'error' ||
1633
+ cardTerminal === 'interrupted' ||
1634
+ cardTerminal === 'idle_timeout') {
1635
+ continue;
1636
+ }
1575
1637
  if (event.type === 'result') {
1576
1638
  sawResult = true;
1577
1639
  resultSubtype = event.subtype;
1578
1640
  resultError = event.errorMessage;
1579
1641
  }
1642
+ watchdog?.touch();
1580
1643
  await cardSession.push(event);
1581
1644
  }
1582
1645
  // §5.3: 卡片终态必须等于引擎真实终态。旧实现只看 sawResult 布尔——error
@@ -1616,7 +1679,26 @@ export class Bridge {
1616
1679
  }
1617
1680
  catch (err) {
1618
1681
  log.error(`[lark-remote] streamCodexCompact failed: ${errorMessage(err)}`);
1619
- await cardSession.finish('error', { errorMsg: errorMessage(err) });
1682
+ // 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
1683
+ // 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
1684
+ const catchTerminal = cardSession.currentState.terminal;
1685
+ if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
1686
+ await cardSession.finish('error', { errorMsg: errorMessage(err) });
1687
+ }
1688
+ else {
1689
+ log.info(`[lark-remote] skip compact error finish: state already terminal (${catchTerminal}) runId=${compactRunId}`);
1690
+ }
1691
+ }
1692
+ finally {
1693
+ watchdog?.stop();
1694
+ // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
1695
+ if (this.activeRuns.get(cwd) === activeRun) {
1696
+ this.activeRuns.delete(cwd);
1697
+ getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${compactRunId}`);
1698
+ }
1699
+ // 终态 reaction:与普通 run 同一口径(done→Done / error→ERROR /
1700
+ // interrupted→SHHH / idle_timeout→Alarm)。
1701
+ this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1620
1702
  }
1621
1703
  }
1622
1704
  /**
@@ -1808,7 +1890,7 @@ export class Bridge {
1808
1890
  getLogger().info(`[lark-remote] executeBash done runId=${runId} exitCode=${exitCode}`);
1809
1891
  }
1810
1892
  // Add a Done emoji reaction to the user's original message to signal completion
1811
- void this.connector.addReaction(ctx.messageId, 'Done');
1893
+ this.finishTurnReactions(ctx, 'Done');
1812
1894
  }
1813
1895
  }
1814
1896
  }
@@ -207,9 +207,9 @@ function renderPlanApproval(approval, expired, runId) {
207
207
  return elements;
208
208
  }
209
209
  /**
210
- * 计划修改意见输入行:输入后点击右侧 ✓ 提交图标,input_value 经 connector
211
- * raw 事件回传(CardKit input 的 input_value 会被 SDK normalizer 丢弃,
212
- * 必须走 includeRawEvent 路径,同 answerNote)。
210
+ * 计划修改意见输入行:输入后提交(移动端键盘回车/完成键,桌面端右侧提交图标),
211
+ * input_value 经 connector raw 事件回传(CardKit input 的 input_value 会被
212
+ * SDK normalizer 丢弃,必须走 includeRawEvent 路径,同 answerNote)。
213
213
  */
214
214
  function renderPlanFeedbackInput(requestId, runId) {
215
215
  return {
@@ -430,8 +430,9 @@ function renderQuestionApproval(approval, expired, runId) {
430
430
  text: { tag: 'lark_md', content: `✍️ 自定义答案:${customSelected}` },
431
431
  });
432
432
  }
433
- // review P3-4:单选问题提供自定义答案(Other)输入——输入文本后点击
434
- // 输入框右侧 ✓ 提交图标,input_value 经 connector raw 事件回传。
433
+ // review P3-4:单选问题提供自定义答案(Other)输入——输入文本后提交
434
+ // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
435
+ // connector raw 事件回传。
435
436
  // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
436
437
  // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
437
438
  if (!q.multiSelect && q.isOther !== false) {
@@ -517,9 +518,9 @@ function renderOptionRow(requestId, questionIndex, option, isSelected, icon, run
517
518
  };
518
519
  }
519
520
  /**
520
- * 文本输入行(Other 输入与自由文本题共用):输入后点击右侧 ✓ 提交图标,
521
- * input_value 经 connector raw 事件回传(CardKit input 的 input_value 会被
522
- * SDK normalizer 丢弃,必须走 includeRawEvent 路径)。
521
+ * 文本输入行(Other 输入与自由文本题共用):输入后提交(移动端键盘回车/完成键,
522
+ * 桌面端右侧提交图标),input_value 经 connector raw 事件回传(CardKit input 的
523
+ * input_value 会被 SDK normalizer 丢弃,必须走 includeRawEvent 路径)。
523
524
  */
524
525
  function renderAnswerInput(requestId, questionIndex, placeholder, runId, cmd = 'approval.answerCustom') {
525
526
  // name 必须含 cmd,保证同一题同时渲染多个输入框(如单选选项题的
@@ -1,4 +1,4 @@
1
- import { truncateUtf8, truncateMarkdownTables, countMarkdownTables, FEISHU_MAX_TABLES, CARD_BUDGET_BYTES, } from './text-truncate.js';
1
+ import { truncateUtf8, dropOldestMarkdownTables, countMarkdownTables, FEISHU_MAX_TABLES, CARD_BUDGET_BYTES, } from './text-truncate.js';
2
2
  /**
3
3
  * 静态卡片体积保护
4
4
  *
@@ -12,16 +12,14 @@ export function enforceCardBudget(card, opts = {}) {
12
12
  // 小体积可以含很多 table,大体积可以没有 table。必须在字节检查之前独立判断。
13
13
  const cardStr = JSON.stringify(card);
14
14
  const bytesBefore = Buffer.byteLength(cardStr, 'utf8');
15
- const totalTables = countCardTables(JSON.parse(cardStr));
16
- if (totalTables > FEISHU_MAX_TABLES) {
17
- // 深拷贝后对每个 lark_md 文本做 truncateMarkdownTables
18
- const fixed = JSON.parse(cardStr);
19
- truncateCardMarkdownTables(fixed);
20
- const fixedStr = JSON.stringify(fixed);
15
+ const parsed = JSON.parse(cardStr);
16
+ const droppedTables = capCardMarkdownTables(parsed, FEISHU_MAX_TABLES);
17
+ if (droppedTables > 0) {
18
+ const fixedStr = JSON.stringify(parsed);
21
19
  // table 截断后可能仍超字节预算,继续走后续阶段
22
20
  if (Buffer.byteLength(fixedStr, 'utf8') <= CARD_BUDGET_BYTES) {
23
21
  return {
24
- card: fixed,
22
+ card: parsed,
25
23
  wasTruncated: true,
26
24
  reason: 'table_count_limited',
27
25
  bytesBefore,
@@ -29,7 +27,7 @@ export function enforceCardBudget(card, opts = {}) {
29
27
  };
30
28
  }
31
29
  // table 修好了但字节仍超,用 fixed 作为后续处理的起点
32
- return enforceByteBudget(fixed, opts, bytesBefore);
30
+ return enforceByteBudget(parsed, opts, bytesBefore);
33
31
  }
34
32
  if (bytesBefore <= CARD_BUDGET_BYTES) {
35
33
  return { card, wasTruncated: false, bytesBefore, bytesAfter: bytesBefore };
@@ -189,7 +187,7 @@ function getPanelIndices(elements) {
189
187
  /**
190
188
  * 遍历卡片元素树(含 collapsible_panel.elements 与 column_set.columns[].elements)。
191
189
  *
192
- * count 与 truncate 两条路径共用同一遍历,避免两套递归漂移(G5 Duplication)。
190
+ * 计数与裁剪两条路径共用同一遍历,避免两套递归漂移(G5 Duplication)。
193
191
  */
194
192
  function walkCardElements(elements, visit) {
195
193
  for (const el of elements) {
@@ -205,29 +203,44 @@ function walkCardElements(elements, visit) {
205
203
  }
206
204
  }
207
205
  /**
208
- * Count all markdown tables across every lark_md text field in a card JSON.
206
+ * 卡片里含 markdown table 的 lark_md 字段,按**文档顺序**(计数与裁剪共用同一
207
+ * 份遍历,避免两套递归漂移)。
209
208
  */
210
- function countCardTables(card) {
211
- let total = 0;
209
+ function tableFields(card) {
210
+ const fields = [];
212
211
  walkCardElements(card?.body?.elements ?? [], (el) => {
213
212
  if (el.text?.tag === 'lark_md' && typeof el.text.content === 'string') {
214
- total += countMarkdownTables(el.text.content);
213
+ const tables = countMarkdownTables(el.text.content);
214
+ if (tables > 0)
215
+ fields.push({ el, tables });
215
216
  }
216
217
  });
217
- return total;
218
+ return fields;
218
219
  }
219
220
  /**
220
- * In-place truncate markdown tables in every lark_md text field of a card,
221
- * keeping at most FEISHU_MAX_TABLES per field.
221
+ * 按**整卡**预算删除 markdown table:`maxTables` 是整张卡的上限,不是每个字段的。
222
+ *
223
+ * 逐字段各限 5 张是空操作(11310 红线失效):table 分散在多个字段时,每个字段
224
+ * 都「没超限」,一张都不会被删,而守卫照样上报 `table_count_limited`。
225
+ * 这里按文档顺序累计,超出预算的**最旧** table 整块删除、保留最新 `maxTables`
226
+ * 张——与 `truncateMarkdownTables()` 的「删最旧、留最新」口径一致。
227
+ *
228
+ * @returns 实际删除的 table 数(0 = 无需处理,未改动 card)
222
229
  */
223
- function truncateCardMarkdownTables(card) {
224
- walkCardElements(card?.body?.elements ?? [], (el) => {
225
- if (el.text?.tag === 'lark_md' && typeof el.text.content === 'string') {
226
- const truncated = truncateMarkdownTables(el.text.content);
227
- if (truncated !== el.text.content) {
228
- el.text.content = truncated;
229
- }
230
- }
231
- });
230
+ function capCardMarkdownTables(card, maxTables) {
231
+ const fields = tableFields(card);
232
+ const total = fields.reduce((sum, f) => sum + f.tables, 0);
233
+ let dropped = 0;
234
+ for (const { el, tables } of fields) {
235
+ // 每轮按「还差几张」重算:某字段的分隔行数可能多于可删块(畸形表),
236
+ // 只按声明数删会漏。
237
+ const toDrop = total - dropped - maxTables;
238
+ if (toDrop <= 0)
239
+ break;
240
+ const content = el.text.content;
241
+ el.text.content = dropOldestMarkdownTables(content, Math.min(toDrop, tables));
242
+ dropped += tables - countMarkdownTables(el.text.content);
243
+ }
244
+ return dropped;
232
245
  }
233
246
  //# sourceMappingURL=card-budget.js.map
@@ -659,11 +659,18 @@ export function renderRunCard(state, options = {}) {
659
659
  if (Buffer.byteLength(JSON.stringify(skeletonCard), 'utf8') <= CARD_BUDGET_BYTES) {
660
660
  return skeletonCard;
661
661
  }
662
- // 理论上不可达:skeleton 已是静态最小结构。若仍超限,硬截断文本提示兜底。
662
+ // 理论上不可达:skeleton 已是静态最小结构。若仍超限(errorMsg 等无界文本
663
+ // 灌进 summary),硬截断只丢 summary——审批入口与 stop/新会话按钮是红线,
664
+ // 任何一级兜底都不能把它们截掉(丢了用户就再也停不下这个 run)。
663
665
  const truncatedSkeleton = {
664
666
  ...skeletonCard,
665
667
  body: {
666
- elements: [statusRow(state), markdownDiv('_⚠️ 输出过大_'), newSessionButton()],
668
+ elements: [
669
+ statusRow(state),
670
+ markdownDiv('_⚠️ 输出过大_'),
671
+ ...approvalArea(state),
672
+ ...actionRow(state, options),
673
+ ],
667
674
  },
668
675
  };
669
676
  return truncatedSkeleton;