lark-remote 0.1.6 → 0.1.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 (188) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +9 -1
  3. package/README.md +14 -2
  4. package/dist/bridge/approval-coordinator.d.ts +57 -34
  5. package/dist/bridge/approval-coordinator.js +191 -40
  6. package/dist/bridge/inbound-media.d.ts +62 -0
  7. package/dist/bridge/inbound-media.js +282 -0
  8. package/dist/bridge/index.d.ts +78 -7
  9. package/dist/bridge/index.js +202 -79
  10. package/dist/bridge/queue-manager.d.ts +2 -1
  11. package/dist/bridge/queue-manager.js +1 -0
  12. package/dist/card/approval-render.js +152 -36
  13. package/dist/card/bash-card-session.d.ts +2 -24
  14. package/dist/card/bash-card-session.js +6 -73
  15. package/dist/card/bash-renderer.js +2 -3
  16. package/dist/card/card-budget.js +29 -75
  17. package/dist/card/card-session.d.ts +48 -0
  18. package/dist/card/card-session.js +86 -0
  19. package/dist/card/card-shared.d.ts +1 -12
  20. package/dist/card/card-shared.js +1 -26
  21. package/dist/card/run-card-session.d.ts +2 -55
  22. package/dist/card/run-card-session.js +6 -104
  23. package/dist/card/run-renderer.js +99 -232
  24. package/dist/card/run-state.d.ts +13 -0
  25. package/dist/card/run-state.js +25 -16
  26. package/dist/card/text-truncate.d.ts +13 -12
  27. package/dist/card/text-truncate.js +13 -63
  28. package/dist/common/fs.d.ts +8 -0
  29. package/dist/common/fs.js +17 -0
  30. package/dist/common/guards.d.ts +0 -20
  31. package/dist/common/guards.js +0 -43
  32. package/dist/common/sleep.d.ts +2 -0
  33. package/dist/common/sleep.js +5 -0
  34. package/dist/common/truncate.d.ts +39 -0
  35. package/dist/common/truncate.js +92 -0
  36. package/dist/config/codex-config.d.ts +1 -1
  37. package/dist/config/codex-config.js +1 -1
  38. package/dist/config/index.d.ts +88 -23
  39. package/dist/config/index.js +105 -34
  40. package/dist/connector/index.d.ts +69 -2
  41. package/dist/connector/index.js +107 -1
  42. package/dist/index.js +123 -105
  43. package/dist/instance-lock.js +4 -12
  44. package/dist/order/alias-resolve.d.ts +13 -0
  45. package/dist/order/alias-resolve.js +22 -0
  46. package/dist/order/alias-store.d.ts +38 -0
  47. package/dist/order/alias-store.js +106 -0
  48. package/dist/persistence/atomic-write.d.ts +8 -2
  49. package/dist/persistence/atomic-write.js +23 -7
  50. package/dist/restart.js +2 -1
  51. package/dist/router/card-helpers.d.ts +63 -0
  52. package/dist/router/card-helpers.js +146 -0
  53. package/dist/router/config/claude.js +25 -1
  54. package/dist/router/config/codex.js +29 -50
  55. package/dist/router/config/common/model-patch.d.ts +22 -0
  56. package/dist/router/config/common/model-patch.js +26 -0
  57. package/dist/router/config/common/render.js +5 -4
  58. package/dist/router/config/index.d.ts +12 -0
  59. package/dist/router/config/index.js +25 -1
  60. package/dist/router/config/kimi.d.ts +4 -1
  61. package/dist/router/config/kimi.js +27 -0
  62. package/dist/router/config/opencode.js +18 -5
  63. package/dist/router/config/pi.js +4 -5
  64. package/dist/router/config/types.d.ts +11 -2
  65. package/dist/router/index.d.ts +56 -21
  66. package/dist/router/index.js +373 -548
  67. package/dist/router/utils.d.ts +2 -0
  68. package/dist/router/utils.js +4 -0
  69. package/dist/runner/agent-choices-common.d.ts +16 -0
  70. package/dist/runner/agent-choices-common.js +31 -0
  71. package/dist/runner/claude/runner.d.ts +47 -10
  72. package/dist/runner/claude/runner.js +117 -46
  73. package/dist/runner/claude/session.d.ts +166 -0
  74. package/dist/runner/claude/session.js +749 -0
  75. package/dist/runner/codex/app-server/protocol-types.d.ts +31 -39
  76. package/dist/runner/codex/app-server/protocol-types.js +1 -12
  77. package/dist/runner/codex/app-server/runner.d.ts +42 -59
  78. package/dist/runner/codex/app-server/runner.js +160 -337
  79. package/dist/runner/codex/app-server/translator.d.ts +1 -21
  80. package/dist/runner/codex/app-server/translator.js +0 -18
  81. package/dist/runner/codex/index.d.ts +0 -2
  82. package/dist/runner/codex/index.js +0 -2
  83. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  84. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  85. package/dist/runner/common/acp/protocol-types.d.ts +238 -0
  86. package/dist/runner/common/acp/protocol-types.js +75 -0
  87. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  88. package/dist/runner/common/connection-based-runner.js +261 -0
  89. package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
  90. package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
  91. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  92. package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
  93. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
  94. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
  95. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  96. package/dist/runner/common/jsonrpc/types.js +9 -0
  97. package/dist/runner/common/process-stopper.d.ts +2 -1
  98. package/dist/runner/common/process-stopper.js +1 -0
  99. package/dist/runner/common/runner-utils.d.ts +6 -13
  100. package/dist/runner/common/runner-utils.js +6 -47
  101. package/dist/runner/common/spawning-runner.d.ts +24 -70
  102. package/dist/runner/common/spawning-runner.js +98 -152
  103. package/dist/runner/index.d.ts +4 -4
  104. package/dist/runner/index.js +4 -4
  105. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  106. package/dist/runner/kimi/acp/runner.js +448 -0
  107. package/dist/runner/kimi/acp/translator.d.ts +124 -0
  108. package/dist/runner/kimi/acp/translator.js +336 -0
  109. package/dist/runner/kimi/index.d.ts +1 -1
  110. package/dist/runner/kimi/index.js +1 -1
  111. package/dist/runner/opencode/acp/index.d.ts +1 -0
  112. package/dist/runner/opencode/acp/index.js +2 -0
  113. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  114. package/dist/runner/opencode/acp/runner.js +429 -0
  115. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  116. package/dist/runner/opencode/acp/translator.js +345 -0
  117. package/dist/runner/opencode/index.d.ts +1 -1
  118. package/dist/runner/opencode/index.js +1 -1
  119. package/dist/runner/pi/index.d.ts +1 -1
  120. package/dist/runner/pi/index.js +1 -1
  121. package/dist/runner/pi/rpc/client.d.ts +52 -0
  122. package/dist/runner/pi/rpc/client.js +114 -0
  123. package/dist/runner/pi/rpc/index.d.ts +4 -0
  124. package/dist/runner/pi/rpc/index.js +4 -0
  125. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  126. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  127. package/dist/runner/pi/rpc/runner.d.ts +80 -0
  128. package/dist/runner/pi/rpc/runner.js +199 -0
  129. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  130. package/dist/runner/pi/rpc/translator.js +258 -0
  131. package/dist/runner/registry.d.ts +7 -8
  132. package/dist/runner/registry.js +3 -4
  133. package/dist/runner/resolve-agent-choices.js +18 -59
  134. package/dist/runner/sync-agent-choices.js +16 -67
  135. package/dist/runner/types.d.ts +43 -13
  136. package/dist/session/claude/session-index.js +2 -3
  137. package/dist/session/claude/session-reader.js +3 -6
  138. package/dist/session/claude/sessions.js +6 -13
  139. package/dist/session/codex/rollout-reader.d.ts +2 -2
  140. package/dist/session/codex/rollout-reader.js +14 -17
  141. package/dist/session/codex/sessions.d.ts +1 -1
  142. package/dist/session/codex/sessions.js +2 -2
  143. package/dist/session/common/content-blocks.d.ts +7 -0
  144. package/dist/session/common/content-blocks.js +13 -9
  145. package/dist/session/common/jsonl.d.ts +8 -25
  146. package/dist/session/common/jsonl.js +126 -261
  147. package/dist/session/common/pagination.d.ts +28 -0
  148. package/dist/session/common/pagination.js +33 -0
  149. package/dist/session/kimi/sessions.d.ts +34 -1
  150. package/dist/session/kimi/sessions.js +208 -98
  151. package/dist/session/opencode/sessions.d.ts +17 -3
  152. package/dist/session/opencode/sessions.js +29 -29
  153. package/dist/session/pi/sessions.d.ts +5 -1
  154. package/dist/session/pi/sessions.js +12 -16
  155. package/dist/session/session-store.d.ts +11 -1
  156. package/dist/session/session-store.js +75 -178
  157. package/dist/update/index.d.ts +2 -2
  158. package/dist/update/index.js +2 -2
  159. package/dist/update/install.d.ts +20 -2
  160. package/dist/update/install.js +51 -7
  161. package/package.json +1 -1
  162. package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
  163. package/dist/runner/codex/argv.d.ts +0 -33
  164. package/dist/runner/codex/argv.js +0 -40
  165. package/dist/runner/codex/factory.d.ts +0 -29
  166. package/dist/runner/codex/factory.js +0 -39
  167. package/dist/runner/codex/jsonl.d.ts +0 -37
  168. package/dist/runner/codex/jsonl.js +0 -221
  169. package/dist/runner/codex/runner.d.ts +0 -66
  170. package/dist/runner/codex/runner.js +0 -129
  171. package/dist/runner/common/exec-translator.d.ts +0 -73
  172. package/dist/runner/common/exec-translator.js +0 -105
  173. package/dist/runner/kimi/jsonl.d.ts +0 -28
  174. package/dist/runner/kimi/jsonl.js +0 -143
  175. package/dist/runner/kimi/runner.d.ts +0 -49
  176. package/dist/runner/kimi/runner.js +0 -105
  177. package/dist/runner/opencode/argv.d.ts +0 -32
  178. package/dist/runner/opencode/argv.js +0 -35
  179. package/dist/runner/opencode/jsonl.d.ts +0 -68
  180. package/dist/runner/opencode/jsonl.js +0 -295
  181. package/dist/runner/opencode/runner.d.ts +0 -61
  182. package/dist/runner/opencode/runner.js +0 -93
  183. package/dist/runner/pi/jsonl.d.ts +0 -128
  184. package/dist/runner/pi/jsonl.js +0 -249
  185. package/dist/runner/pi/runner.d.ts +0 -36
  186. package/dist/runner/pi/runner.js +0 -76
  187. package/dist/session/common/catch-up.d.ts +0 -12
  188. package/dist/session/common/catch-up.js +0 -31
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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.1.7] - 2026-08-18
9
+
10
+ ### 新增
11
+
12
+ - pi RPC 模式 runner:通过持久连接复用共享 `ConnectionManager`,支持审批与上下文压缩
13
+ - kimi / opencode ACP 模式 runner:纯 ACP 持久连接,支持审批 + Compact
14
+ - 共享 ACP / JSON-RPC 连接层(`runner/common/acp`、`runner/common/jsonrpc`):统一 codex / kimi / opencode / pi 的连接管理
15
+ - 入站图片/文件自动落盘:往飞书私聊发图片/文件自动保存到 `.lark-remote-temp/`,可直接让 agent 处理
16
+ - `/order alias`:注册快捷别名,输入 `$name` 即展开
17
+ - 会话分页/截断共享辅助模块(`session/common/pagination`)
18
+ - Codex 命令审批卡片示例图
19
+
20
+ ### 变更
21
+
22
+ - codex runner 统一走 app-server 审批模式;移除 codex/kimi/opencode/pi 的 exec/jsonl 旧模式与死代码
23
+ - `codex-bundled-test-helpers` 迁移至 `tests/lib/`
24
+ - 用户文档:新增入站文件、`/order alias`、kimi acp 配置说明
25
+
26
+ ### 修复
27
+
28
+ - 修复文档引用指向被排除的本地文档导致的破链
29
+
8
30
  ## [0.1.6] - 2026-08-14
9
31
 
10
32
  ### 新增
@@ -14,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
36
  - Codex runner 工厂:自动选择直连模式或 App Server 模式
15
37
  - `rollout-reader`:Codex App Server rollout 开关读取
16
38
  - Run card 审批区域渲染 + context limit 百分比显示
17
- - 架构文档:`codex-app-server-integration.md`
39
+ - 架构文档:`docs/zh/guides/codex-config.md`
18
40
 
19
41
  ### 修复
20
42
 
package/README.en.md CHANGED
@@ -16,13 +16,17 @@ Designed for a single-user, peer-to-peer private chat. The agent's system prompt
16
16
 
17
17
  ## ⚠️ Security Warning (read first)
18
18
 
19
- This tool turns Feishu messages directly into local agent executions, and agents run with **full permissions** by default (Claude `bypassPermissions`, Codex `approval_policy=never` + full access) — effectively handing a shell on your machine to whoever can chat with the bot. You MUST:
19
+ This tool turns Feishu messages directly into local agent executions. Claude runs with **full permissions** by default (fixed `bypassPermissions`); Codex uses the **app-server approval mode** (default `approvalPolicy=on-request` — command execution requires your confirmation on the Feishu card, sandbox defaults to `workspace-write`), which is relatively safe; other agents run with their CLI defaults. Agents can still read and write the local directories you specify. You MUST:
20
20
 
21
21
  - **Use it only in your own private (p2p) chat.** Never add the bot to any group chat; never let anyone else talk to it.
22
22
  - Restrict the app's visibility to **yourself only** in the Feishu Open Platform.
23
23
  - Don't run it on a machine holding data you can't afford to lose; prefer a dedicated user / machine / container.
24
24
  - Keep credentials such as `appSecret` in your local `config.yaml` only — **never commit them anywhere**.
25
25
 
26
+ In Codex approval mode, an approval card arrives before any command runs — approve or reject it right in Feishu:
27
+
28
+ ![Codex command approval card example](docs/images/approval-card.png)
29
+
26
30
  ## Prerequisites
27
31
 
28
32
  - OS: macOS / Linux. **Windows is not supported yet** (relies on POSIX behaviors such as Unix signals, bash, and file locks)
@@ -153,6 +157,10 @@ Full docs under [`docs/`](docs/):
153
157
 
154
158
  > This package is a CLI tool only — no programmatic API is exposed.
155
159
 
160
+ ## Acknowledgments
161
+
162
+ This project was inspired by [lark-coding-agent-bridge](https://github.com/zarazhangrui/lark-coding-agent-bridge) — thanks for demonstrating the feasibility of bridging Feishu with local coding agents.
163
+
156
164
  ## License
157
165
 
158
166
  [MIT](LICENSE) © bungabungawoda
package/README.md CHANGED
@@ -16,13 +16,17 @@
16
16
 
17
17
  ## ⚠️ 安全警告(必读)
18
18
 
19
- 本工具会把飞书消息直接转为本地 agent 执行,agent 默认以**完全权限**运行(Claude `bypassPermissions`、Codex `approval_policy=never` + full access),等价于把一台机器的 shell 交给聊天对端。请务必:
19
+ 本工具会把飞书消息直接转为本地 agent 执行。Claude 默认以**完全权限**运行(固定 `bypassPermissions`);Codex 采用 **app-server 审批模式**(默认 `approvalPolicy=on-request`,执行命令需在飞书卡片上确认,沙箱默认 `workspace-write`),相对安全;其他 agent 以各自 CLI 的默认权限运行。agent 仍可读写你指定的本地目录,请务必:
20
20
 
21
21
  - **仅限你自己的私聊(p2p)使用**。不要把机器人拉进任何群聊,不要让任何其他人能与它对话。
22
22
  - 在飞书开放平台把应用可见范围限制到**只有你自己**。
23
23
  - 运行本工具的机器上不要存放与损失承受力不符的数据;建议在独立用户/机器/容器里运行。
24
24
  - `appSecret` 等凭据只在本地 `config.yaml`,**不要提交到任何仓库**。
25
25
 
26
+ Codex 审批模式下,执行命令前会收到审批卡片,可直接在飞书里允许或拒绝:
27
+
28
+ ![Codex 命令审批卡片示例](docs/images/approval-card.png)
29
+
26
30
  ## 前置条件
27
31
 
28
32
  - 操作系统:macOS / Linux。**暂不支持 Windows**(依赖 Unix 信号、bash、文件锁等 POSIX 行为)
@@ -120,12 +124,16 @@ bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYY
120
124
  | `/reconnect` | - | 重连飞书 |
121
125
  | `/restart` | - | 原地自重启 bridge(新进程同 config 接管) |
122
126
  | `/config get\|set` | `/c` | 查改运行时配置(agent-aware 卡片) |
123
- | `/order save\|list` | `/o` | 保存或列出常用指令 |
127
+ | `/order save\|list\|alias` | `/o` | 收藏常用指令;`/order alias` 注册快捷别名(输入 `$name` 展开) |
124
128
  | `!<cmd>` | - | 执行 bash 命令并流式输出到卡片(绕过串行队列) |
125
129
  | `/exit` | `/e` | 退出 bridge |
126
130
 
127
131
  直接发非 `/` 开头的消息即转发给当前默认 agent。
128
132
 
133
+ 往飞书私聊发**图片/文件**会自动保存到当前目录的 `.lark-remote-temp/<YYYYMMDDHHmm>/`
134
+ (建议把 `.lark-remote-temp/` 加进项目 `.gitignore`),随后说「请处理刚才保存的文件」
135
+ 即可让 agent 直接读本地文件。更多用法见 [docs/zh/usage.md](docs/zh/usage.md)。
136
+
129
137
  ## 测试
130
138
 
131
139
  ```bash
@@ -152,6 +160,10 @@ FEISHU_LIVE_TEST=1 bun run test tests/feishu-reaction-emoji-live.test.ts
152
160
 
153
161
  > 本包为纯 CLI 工具,不提供编程 API。
154
162
 
163
+ ## 致谢
164
+
165
+ 本项目受到了 [lark-coding-agent-bridge](https://github.com/zarazhangrui/lark-coding-agent-bridge) 的启发,感谢该项目展示了飞书与本地 Coding Agent 桥接的可行性。
166
+
155
167
  ## License
156
168
 
157
169
  [MIT](LICENSE) © bungabungawoda
@@ -6,30 +6,23 @@
6
6
  * - onResolved: update state, clear timer
7
7
  * - submit: validate → atomic state transition → async respond
8
8
  * - togglePerm: flip permission item selection
9
- * - onTurnEnded / onConnectionLost: mark all as expired
9
+ * - onTurnEnded: mark all as expired
10
10
  */
11
11
  import type { ApprovalRequestedEvent, ApprovalView, AgentEvent } from '../runner/types.js';
12
- export type ApprovalState = 'pending' | 'submitting' | 'resolved' | 'failed' | 'expired';
12
+ import type { ApprovalAction } from '../runner/types.js';
13
+ type ApprovalState = 'pending' | 'submitting' | 'resolved' | 'failed' | 'expired';
13
14
  export interface TrackedApproval {
14
15
  requestId: number | string;
15
- kind: 'command' | 'file' | 'permissions';
16
+ kind: 'command' | 'file' | 'permissions' | 'question';
16
17
  state: ApprovalState;
17
18
  view: ApprovalView;
18
19
  createdAt: number;
19
20
  timeoutTimer: ReturnType<typeof setTimeout> | null;
21
+ /** AskUserQuestion 已答问题索引 → 答案 label[](kind === 'question')。 */
22
+ answers?: Map<number, string[]>;
20
23
  }
21
- export type ApprovalResponder = (requestId: number | string, response: unknown) => Promise<void>;
22
- export type ApprovalAction = {
23
- action: 'accept';
24
- } | {
25
- action: 'accept_for_session';
26
- } | {
27
- action: 'accept_with_execpolicy_amendment';
28
- } | {
29
- action: 'decline';
30
- } | {
31
- action: 'cancel';
32
- };
24
+ type ApprovalResponder = (requestId: number | string, response: unknown) => Promise<void>;
25
+ export type { ApprovalAction } from '../runner/types.js';
33
26
  export interface ApprovalToggleAction {
34
27
  permId: string;
35
28
  selected: boolean;
@@ -38,19 +31,11 @@ export declare class ApprovalCoordinator {
38
31
  private approvals;
39
32
  /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
40
33
  private submittedNonces;
41
- private readonly runId;
42
- private readonly userId;
43
- private readonly chatId;
44
- private readonly workspace;
45
34
  private readonly approvalTimeoutMs;
46
35
  private readonly responder;
47
36
  private readonly interruptTurn;
48
37
  private readonly pushToCard;
49
38
  constructor(opts: {
50
- runId: string;
51
- userId: string;
52
- chatId: string;
53
- workspace: string;
54
39
  approvalTimeoutMs: number;
55
40
  responder: ApprovalResponder;
56
41
  interruptTurn: () => Promise<void>;
@@ -68,24 +53,54 @@ export declare class ApprovalCoordinator {
68
53
  * Handle turn ended — mark all pending approvals as expired.
69
54
  */
70
55
  onTurnEnded(): void;
71
- /**
72
- * Handle connection lost — same as onTurnEnded.
73
- */
74
- onConnectionLost(): void;
75
56
  /**
76
57
  * Submit an approval decision.
77
- * Returns a promise that resolves when the response is sent.
78
58
  */
79
59
  submit(action: ApprovalAction, ctx: {
80
60
  requestId: number | string;
81
61
  nonce?: string;
82
- }): Promise<string>;
62
+ }): Promise<void>;
83
63
  /**
84
64
  * Toggle a permission item's selected state.
85
65
  */
86
66
  togglePerm(action: ApprovalToggleAction, ctx: {
87
67
  requestId: number | string;
88
- }): Promise<string>;
68
+ }): Promise<void>;
69
+ /**
70
+ * Claude AskUserQuestion 选项点击。
71
+ *
72
+ * - 单选:点击即选中该选项;全部问题答完时自动通过 responder 提交答案。
73
+ * - 多选:切换 selected 并推 approval_view_updated 重渲染,等待
74
+ * submitAnswers() 显式提交。
75
+ */
76
+ toggleAnswer(action: {
77
+ questionIndex: number;
78
+ option: string;
79
+ }, ctx: {
80
+ requestId: number | string;
81
+ nonce?: string;
82
+ }): Promise<void>;
83
+ /**
84
+ * Claude AskUserQuestion 多选问题的「提交答案」按钮。
85
+ * 提交该问题当前勾选的选项;全部问题答完时通过 responder 提交。
86
+ */
87
+ submitAnswers(action: {
88
+ questionIndex: number;
89
+ }, ctx: {
90
+ requestId: number | string;
91
+ nonce?: string;
92
+ }): Promise<void>;
93
+ /**
94
+ * AskUserQuestion 自定义答案(Other,review P3-4):自由文本直接作为该
95
+ * 单选问题的答案。与单选 toggle 同语义:全部问题答完自动提交。
96
+ */
97
+ answerCustom(action: {
98
+ questionIndex: number;
99
+ text: string;
100
+ }, ctx: {
101
+ requestId: number | string;
102
+ nonce?: string;
103
+ }): Promise<void>;
89
104
  /**
90
105
  * Update a pending approval's view (out-of-order item/started arrives late).
91
106
  * Only applies while the approval is still pending — resolved/expired
@@ -96,11 +111,19 @@ export declare class ApprovalCoordinator {
96
111
  * Get the number of pending approvals.
97
112
  */
98
113
  pendingCount(): number;
99
- /**
100
- * Get the head (oldest pending) approval, or undefined.
101
- */
102
- head(): TrackedApproval | undefined;
103
114
  private clearTimer;
115
+ /** nonce 去重:同一 nonce 只允许生效一次(防连点/飞书重投递)。 */
116
+ private assertFreshNonce;
117
+ /** 记录问题答案到 tracked.answers(懒初始化 Map)。 */
118
+ private recordAnswer;
119
+ private getPendingQuestion;
120
+ private allQuestionsAnswered;
121
+ /** 推送 question 卡片视图更新(选项勾选状态变化)。 */
122
+ private pushQuestionViewUpdate;
123
+ /** 全部问题已答:构建 answers 并提交(非空校验 + 原子状态迁移)。 */
124
+ private respondQuestionAnswers;
125
+ /** 推 approval_resolved 到卡片(移除审批区;幂等,失败仅记日志)。 */
126
+ private pushResolved;
104
127
  private expireApproval;
105
128
  private actionToDecision;
106
129
  }
@@ -6,7 +6,7 @@
6
6
  * - onResolved: update state, clear timer
7
7
  * - submit: validate → atomic state transition → async respond
8
8
  * - togglePerm: flip permission item selection
9
- * - onTurnEnded / onConnectionLost: mark all as expired
9
+ * - onTurnEnded: mark all as expired
10
10
  */
11
11
  import { getLogger } from '../logger/index.js';
12
12
  // =============================================================================
@@ -16,19 +16,11 @@ export class ApprovalCoordinator {
16
16
  approvals = new Map();
17
17
  /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
18
18
  submittedNonces = new Map();
19
- runId;
20
- userId;
21
- chatId;
22
- workspace;
23
19
  approvalTimeoutMs;
24
20
  responder;
25
21
  interruptTurn;
26
22
  pushToCard;
27
23
  constructor(opts) {
28
- this.runId = opts.runId;
29
- this.userId = opts.userId;
30
- this.chatId = opts.chatId;
31
- this.workspace = opts.workspace;
32
24
  this.approvalTimeoutMs = opts.approvalTimeoutMs;
33
25
  this.responder = opts.responder;
34
26
  this.interruptTurn = opts.interruptTurn;
@@ -79,15 +71,8 @@ export class ApprovalCoordinator {
79
71
  }
80
72
  }
81
73
  }
82
- /**
83
- * Handle connection lost — same as onTurnEnded.
84
- */
85
- onConnectionLost() {
86
- this.onTurnEnded();
87
- }
88
74
  /**
89
75
  * Submit an approval decision.
90
- * Returns a promise that resolves when the response is sent.
91
76
  */
92
77
  async submit(action, ctx) {
93
78
  const tracked = this.approvals.get(ctx.requestId);
@@ -97,14 +82,7 @@ export class ApprovalCoordinator {
97
82
  // nonce 去重:同一张卡片渲染出的按钮连续点击(或飞书重投递)携带相同
98
83
  // nonce,只允许生效一次;不同渲染的按钮 nonce 不同,状态机(pending →
99
84
  // submitting)仍然兜底防二次提交。
100
- if (ctx.nonce !== undefined) {
101
- const seen = this.submittedNonces.get(ctx.requestId) ?? new Set();
102
- if (seen.has(ctx.nonce)) {
103
- throw new Error(`Approval request ${ctx.requestId} already submitted (duplicate nonce)`);
104
- }
105
- seen.add(ctx.nonce);
106
- this.submittedNonces.set(ctx.requestId, seen);
107
- }
85
+ this.assertFreshNonce(ctx.requestId, ctx.nonce);
108
86
  if (tracked.state !== 'pending') {
109
87
  throw new Error(`Approval request ${ctx.requestId} is no longer pending (state=${tracked.state})`);
110
88
  }
@@ -123,11 +101,14 @@ export class ApprovalCoordinator {
123
101
  try {
124
102
  await this.responder(ctx.requestId, action);
125
103
  tracked.state = 'resolved';
104
+ // 提交成功后立即从卡片移除审批区(agent 无关):claude 控制协议没有
105
+ // resolved 回发,不推就会「已点允许按钮仍在」残留 UI;codex 的 server
106
+ // 稍后也会发 approval_resolved,reducer 按 requestId 过滤幂等。
107
+ this.pushResolved(ctx.requestId);
126
108
  }
127
109
  catch {
128
110
  tracked.state = 'failed';
129
111
  }
130
- return `Approval ${tracked.requestId} (${tracked.kind}): ${decision}`;
131
112
  }
132
113
  /**
133
114
  * Toggle a permission item's selected state.
@@ -152,7 +133,96 @@ export class ApprovalCoordinator {
152
133
  throw new Error(`Permission item ${action.permId} not found in request ${ctx.requestId}`);
153
134
  }
154
135
  item.selected = action.selected;
155
- return `Permission ${action.permId}: ${action.selected ? 'granted' : 'denied'}`;
136
+ }
137
+ /**
138
+ * Claude AskUserQuestion 选项点击。
139
+ *
140
+ * - 单选:点击即选中该选项;全部问题答完时自动通过 responder 提交答案。
141
+ * - 多选:切换 selected 并推 approval_view_updated 重渲染,等待
142
+ * submitAnswers() 显式提交。
143
+ */
144
+ async toggleAnswer(action, ctx) {
145
+ const tracked = this.getPendingQuestion(ctx.requestId);
146
+ const questions = tracked.view.questions;
147
+ const q = questions[action.questionIndex];
148
+ if (!q) {
149
+ throw new Error(`Question ${action.questionIndex} not found in request ${ctx.requestId}`);
150
+ }
151
+ const option = q.options.find((o) => o.label === action.option);
152
+ if (!option) {
153
+ throw new Error(`Option "${action.option}" not found in question ${action.questionIndex}`);
154
+ }
155
+ const selected = new Set(q.selected ?? []);
156
+ if (q.multiSelect) {
157
+ // review P2-1:多选切换按钮同一 nonce 重复投递(双击/飞书重投递)只应
158
+ // toggle 一次,否则勾选会被二次 toggle 抵消。单选用例的重复防护在
159
+ // respondQuestionAnswers(提交即幂等,选中是 set 语义)。
160
+ this.assertFreshNonce(ctx.requestId, ctx.nonce);
161
+ if (selected.has(action.option)) {
162
+ selected.delete(action.option);
163
+ }
164
+ else {
165
+ selected.add(action.option);
166
+ }
167
+ q.selected = [...selected];
168
+ this.pushQuestionViewUpdate(ctx.requestId, tracked);
169
+ return;
170
+ }
171
+ // 单选:替换选择;全部问题答完立即提交,否则等待后续问题。
172
+ q.selected = [action.option];
173
+ this.recordAnswer(tracked, action.questionIndex, [action.option]);
174
+ if (this.allQuestionsAnswered(tracked)) {
175
+ return this.respondQuestionAnswers(tracked, ctx);
176
+ }
177
+ this.pushQuestionViewUpdate(ctx.requestId, tracked);
178
+ }
179
+ /**
180
+ * Claude AskUserQuestion 多选问题的「提交答案」按钮。
181
+ * 提交该问题当前勾选的选项;全部问题答完时通过 responder 提交。
182
+ */
183
+ async submitAnswers(action, ctx) {
184
+ const tracked = this.getPendingQuestion(ctx.requestId);
185
+ const questions = tracked.view.questions;
186
+ const q = questions[action.questionIndex];
187
+ if (!q) {
188
+ throw new Error(`Question ${action.questionIndex} not found in request ${ctx.requestId}`);
189
+ }
190
+ if (!q.multiSelect) {
191
+ throw new Error('该问题为单选,请直接点击选项');
192
+ }
193
+ const selected = q.selected ?? [];
194
+ if (selected.length === 0) {
195
+ throw new Error('请先选择至少一个选项');
196
+ }
197
+ this.recordAnswer(tracked, action.questionIndex, selected);
198
+ if (this.allQuestionsAnswered(tracked)) {
199
+ return this.respondQuestionAnswers(tracked, ctx);
200
+ }
201
+ }
202
+ /**
203
+ * AskUserQuestion 自定义答案(Other,review P3-4):自由文本直接作为该
204
+ * 单选问题的答案。与单选 toggle 同语义:全部问题答完自动提交。
205
+ */
206
+ async answerCustom(action, ctx) {
207
+ const tracked = this.getPendingQuestion(ctx.requestId);
208
+ const questions = tracked.view.questions;
209
+ const q = questions[action.questionIndex];
210
+ if (!q) {
211
+ throw new Error(`Question ${action.questionIndex} not found in request ${ctx.requestId}`);
212
+ }
213
+ if (q.multiSelect) {
214
+ throw new Error('自定义答案仅支持单选');
215
+ }
216
+ const text = action.text.trim();
217
+ if (!text) {
218
+ throw new Error('请输入自定义答案');
219
+ }
220
+ q.selected = [text];
221
+ this.recordAnswer(tracked, action.questionIndex, [text]);
222
+ if (this.allQuestionsAnswered(tracked)) {
223
+ return this.respondQuestionAnswers(tracked, ctx);
224
+ }
225
+ this.pushQuestionViewUpdate(ctx.requestId, tracked);
156
226
  }
157
227
  /**
158
228
  * Update a pending approval's view (out-of-order item/started arrives late).
@@ -176,20 +246,6 @@ export class ApprovalCoordinator {
176
246
  }
177
247
  return count;
178
248
  }
179
- /**
180
- * Get the head (oldest pending) approval, or undefined.
181
- */
182
- head() {
183
- let oldest;
184
- let oldestTime = Infinity;
185
- for (const [, tracked] of this.approvals) {
186
- if (tracked.state === 'pending' && tracked.createdAt < oldestTime) {
187
- oldest = tracked;
188
- oldestTime = tracked.createdAt;
189
- }
190
- }
191
- return oldest;
192
- }
193
249
  // =========================================================================
194
250
  // Internal
195
251
  // =========================================================================
@@ -199,6 +255,95 @@ export class ApprovalCoordinator {
199
255
  tracked.timeoutTimer = null;
200
256
  }
201
257
  }
258
+ /** nonce 去重:同一 nonce 只允许生效一次(防连点/飞书重投递)。 */
259
+ assertFreshNonce(requestId, nonce) {
260
+ if (nonce === undefined)
261
+ return;
262
+ const seen = this.submittedNonces.get(requestId) ?? new Set();
263
+ if (seen.has(nonce)) {
264
+ throw new Error(`Approval request ${requestId} already submitted (duplicate nonce)`);
265
+ }
266
+ seen.add(nonce);
267
+ this.submittedNonces.set(requestId, seen);
268
+ }
269
+ /** 记录问题答案到 tracked.answers(懒初始化 Map)。 */
270
+ recordAnswer(tracked, questionIndex, values) {
271
+ tracked.answers ??= new Map();
272
+ tracked.answers.set(questionIndex, values);
273
+ }
274
+ getPendingQuestion(requestId) {
275
+ const tracked = this.approvals.get(requestId);
276
+ if (!tracked) {
277
+ throw new Error(`Approval request ${requestId} not found`);
278
+ }
279
+ if (tracked.kind !== 'question') {
280
+ throw new Error(`Approval request ${requestId} is not a question request`);
281
+ }
282
+ if (tracked.state !== 'pending') {
283
+ throw new Error(`Approval request ${requestId} is no longer pending`);
284
+ }
285
+ return tracked;
286
+ }
287
+ allQuestionsAnswered(tracked) {
288
+ const questions = tracked.view.questions ?? [];
289
+ if (questions.length === 0)
290
+ return false;
291
+ for (let i = 0; i < questions.length; i++) {
292
+ const answered = (tracked.answers?.get(i)?.length ?? 0) > 0;
293
+ if (!answered)
294
+ return false;
295
+ }
296
+ return true;
297
+ }
298
+ /** 推送 question 卡片视图更新(选项勾选状态变化)。 */
299
+ pushQuestionViewUpdate(requestId, tracked) {
300
+ void this.pushToCard([
301
+ {
302
+ type: 'approval_view_updated',
303
+ requestId,
304
+ view: tracked.view,
305
+ timestamp: new Date().toISOString(),
306
+ },
307
+ ]).catch((err) => {
308
+ getLogger().warn(`[approval-coordinator] question view update failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
309
+ });
310
+ }
311
+ /** 全部问题已答:构建 answers 并提交(非空校验 + 原子状态迁移)。 */
312
+ async respondQuestionAnswers(tracked, ctx) {
313
+ this.assertFreshNonce(ctx.requestId, ctx.nonce);
314
+ if (tracked.state !== 'pending') {
315
+ throw new Error(`Approval request ${ctx.requestId} is no longer pending (state=${tracked.state})`);
316
+ }
317
+ const questions = tracked.view.questions ?? [];
318
+ const answers = {};
319
+ for (let i = 0; i < questions.length; i++) {
320
+ const selected = tracked.answers?.get(i) ?? [];
321
+ answers[questions[i].question] = questions[i].multiSelect ? selected : selected[0];
322
+ }
323
+ tracked.state = 'submitting';
324
+ this.clearTimer(tracked);
325
+ try {
326
+ await this.responder(ctx.requestId, { action: 'answer', answers });
327
+ tracked.state = 'resolved';
328
+ this.pushResolved(ctx.requestId);
329
+ }
330
+ catch {
331
+ tracked.state = 'failed';
332
+ }
333
+ }
334
+ /** 推 approval_resolved 到卡片(移除审批区;幂等,失败仅记日志)。 */
335
+ pushResolved(requestId) {
336
+ void this.pushToCard([
337
+ {
338
+ type: 'approval_resolved',
339
+ requestId,
340
+ outcome: 'resolved',
341
+ timestamp: new Date().toISOString(),
342
+ },
343
+ ]).catch((err) => {
344
+ getLogger().warn(`[approval-coordinator] resolved card event failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
345
+ });
346
+ }
202
347
  expireApproval(requestId) {
203
348
  const tracked = this.approvals.get(requestId);
204
349
  if (!tracked || tracked.state !== 'pending')
@@ -231,6 +376,10 @@ export class ApprovalCoordinator {
231
376
  return 'acceptForSession';
232
377
  case 'accept_with_execpolicy_amendment':
233
378
  return 'acceptWithExecpolicyAmendment';
379
+ case 'accept_all':
380
+ return 'acceptAll';
381
+ case 'answer':
382
+ return 'answer';
234
383
  case 'decline':
235
384
  return 'decline';
236
385
  case 'cancel':
@@ -247,6 +396,8 @@ export function decisionToApprovalAction(decision) {
247
396
  return { action: 'accept_for_session' };
248
397
  case 'acceptWithExecpolicyAmendment':
249
398
  return { action: 'accept_with_execpolicy_amendment' };
399
+ case 'acceptAll':
400
+ return { action: 'accept_all' };
250
401
  case 'decline':
251
402
  return { action: 'decline' };
252
403
  case 'cancel':
@@ -0,0 +1,62 @@
1
+ import { type AppConfig } from '../config/index.js';
2
+ import type { InboundMediaItem, InboundMediaPayload } from '../connector/index.js';
3
+ export interface InboundMediaDeps {
4
+ /** 解析用户 cwd(bridge.resolveCwd 注入,统一三份复制点)。 */
5
+ resolveCwd: (userId: string) => string | undefined;
6
+ /** 读取当前配置(活引用,随 /config 保存更新,避免启动快照过期)。 */
7
+ getConfig: () => AppConfig;
8
+ /** 发送提示消息(bridge.sendResult 包装)。 */
9
+ send(ctx: {
10
+ userId: string;
11
+ chatId: string;
12
+ messageId: string;
13
+ }, text: string): Promise<boolean>;
14
+ }
15
+ /**
16
+ * 文件名 sanitize:
17
+ * path.basename 剥离目录(防 `../` 穿越),再替换控制字符与路径分隔符;
18
+ * 空名 / `.` / `..` 视为无效(返回空串由调用方生成兜底名)。
19
+ */
20
+ export declare function sanitizeFileName(name: string): string;
21
+ /**
22
+ * image 消息扩展名:优先 MIME 映射,未知时按魔数兜底;
23
+ * 两者都无法识别时返回 undefined(调用方省略扩展名,避免错误标注格式)。
24
+ */
25
+ export declare function imageExtension(mimeType: string | undefined, head: Buffer): string | undefined;
26
+ /** 按 UTF-8 字节数截断字符串(避免截在多字节字符中间)。 */
27
+ import { truncateUtf8 } from '../common/truncate.js';
28
+ export { truncateUtf8 };
29
+ /**
30
+ * 文件名长度限制(字节级,保留扩展名),避免 ENAMETOOLONG 整文件失败。
31
+ * 极罕见情况(扩展名本身超限)直接截断全名。
32
+ */
33
+ export declare function limitFileNameLength(name: string, maxBytes?: number): string;
34
+ /**
35
+ * 生成落盘文件名:
36
+ * - file 消息:保留原始文件名(已 sanitize);无文件名兜底 file_<HHmmss>_<n>;
37
+ * - image 消息:image_<HHmmss>_<n>.<ext>,ext 按 MIME。
38
+ */
39
+ export declare function buildFileName(item: InboundMediaItem, index: number, receivedAt: Date): string;
40
+ /** 同名冲突自动加序号(name-1.ext、name-2.ext…),不覆盖已有文件。 */
41
+ export declare function uniqueTargetPath(dir: string, fileName: string): string;
42
+ export declare class InboundMediaHandler {
43
+ private readonly deps;
44
+ private readonly pending;
45
+ private readonly batchWindowMs;
46
+ constructor(deps: InboundMediaDeps);
47
+ /** 落盘并安排/合并提示。 */
48
+ handle(payload: InboundMediaPayload): Promise<void>;
49
+ /** 立即冲刷某用户的全部待合批提示(文本到达时调用)。 */
50
+ flushAll(userId: string, chatId: string): void;
51
+ /** 冲刷全部待合批提示(/exit、/restart 干净退出前调用,避免丢最后一条提示)。 */
52
+ flushAllPending(): Promise<void>;
53
+ /**
54
+ * dirName 配置校验:只允许单层目录名(不允许 `/`、`\`、`..`、`.`、控制字符),
55
+ * 非法值回退默认 `.lark-remote-temp`,避免配置意外把文件写到 cwd 之外。
56
+ */
57
+ private safeDirName;
58
+ /** 清理未被移动的临时文件(无 cwd / 目录创建失败等提前返回路径)。 */
59
+ private cleanupTemps;
60
+ private resolveCwd;
61
+ private flush;
62
+ }