lark-remote 0.1.5 → 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 (190) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +5 -1
  3. package/README.md +10 -2
  4. package/dist/bridge/approval-coordinator.d.ts +131 -0
  5. package/dist/bridge/approval-coordinator.js +409 -0
  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 +138 -5
  9. package/dist/bridge/index.js +569 -65
  10. package/dist/bridge/queue-manager.d.ts +11 -1
  11. package/dist/bridge/queue-manager.js +20 -11
  12. package/dist/card/approval-render.d.ts +16 -0
  13. package/dist/card/approval-render.js +363 -0
  14. package/dist/card/bash-card-session.d.ts +2 -24
  15. package/dist/card/bash-card-session.js +6 -73
  16. package/dist/card/bash-renderer.js +2 -3
  17. package/dist/card/card-budget.js +29 -75
  18. package/dist/card/card-session.d.ts +48 -0
  19. package/dist/card/card-session.js +86 -0
  20. package/dist/card/card-shared.d.ts +13 -10
  21. package/dist/card/card-shared.js +27 -24
  22. package/dist/card/run-card-session.d.ts +2 -55
  23. package/dist/card/run-card-session.js +6 -104
  24. package/dist/card/run-renderer.js +140 -202
  25. package/dist/card/run-state.d.ts +38 -3
  26. package/dist/card/run-state.js +221 -11
  27. package/dist/card/text-truncate.d.ts +13 -12
  28. package/dist/card/text-truncate.js +13 -63
  29. package/dist/common/fs.d.ts +8 -0
  30. package/dist/common/fs.js +17 -0
  31. package/dist/common/guards.d.ts +0 -20
  32. package/dist/common/guards.js +0 -43
  33. package/dist/common/sleep.d.ts +2 -0
  34. package/dist/common/sleep.js +5 -0
  35. package/dist/common/truncate.d.ts +39 -0
  36. package/dist/common/truncate.js +92 -0
  37. package/dist/config/codex-config.d.ts +1 -1
  38. package/dist/config/codex-config.js +1 -1
  39. package/dist/config/dir.js +1 -1
  40. package/dist/config/index.d.ts +116 -9
  41. package/dist/config/index.js +122 -29
  42. package/dist/connector/index.d.ts +74 -2
  43. package/dist/connector/index.js +119 -1
  44. package/dist/index.js +139 -99
  45. package/dist/instance-lock.js +4 -12
  46. package/dist/order/alias-resolve.d.ts +13 -0
  47. package/dist/order/alias-resolve.js +22 -0
  48. package/dist/order/alias-store.d.ts +38 -0
  49. package/dist/order/alias-store.js +106 -0
  50. package/dist/persistence/atomic-write.d.ts +8 -2
  51. package/dist/persistence/atomic-write.js +23 -7
  52. package/dist/restart.js +2 -1
  53. package/dist/router/card-helpers.d.ts +63 -0
  54. package/dist/router/card-helpers.js +146 -0
  55. package/dist/router/config/claude.js +25 -1
  56. package/dist/router/config/codex.d.ts +1 -1
  57. package/dist/router/config/codex.js +33 -5
  58. package/dist/router/config/common/model-patch.d.ts +22 -0
  59. package/dist/router/config/common/model-patch.js +26 -0
  60. package/dist/router/config/common/render.js +9 -4
  61. package/dist/router/config/index.d.ts +12 -0
  62. package/dist/router/config/index.js +25 -1
  63. package/dist/router/config/kimi.d.ts +4 -1
  64. package/dist/router/config/kimi.js +27 -0
  65. package/dist/router/config/opencode.js +18 -5
  66. package/dist/router/config/pi.js +4 -5
  67. package/dist/router/config/types.d.ts +12 -3
  68. package/dist/router/index.d.ts +77 -17
  69. package/dist/router/index.js +455 -549
  70. package/dist/router/utils.d.ts +4 -0
  71. package/dist/router/utils.js +9 -2
  72. package/dist/runner/agent-choices-common.d.ts +16 -0
  73. package/dist/runner/agent-choices-common.js +31 -0
  74. package/dist/runner/claude/runner.d.ts +47 -10
  75. package/dist/runner/claude/runner.js +117 -46
  76. package/dist/runner/claude/session.d.ts +166 -0
  77. package/dist/runner/claude/session.js +749 -0
  78. package/dist/runner/codex/app-server/protocol-types.d.ts +519 -0
  79. package/dist/runner/codex/app-server/protocol-types.js +56 -0
  80. package/dist/runner/codex/app-server/runner.d.ts +104 -0
  81. package/dist/runner/codex/app-server/runner.js +377 -0
  82. package/dist/runner/codex/app-server/translator.d.ts +89 -0
  83. package/dist/runner/codex/app-server/translator.js +716 -0
  84. package/dist/runner/codex/index.d.ts +1 -1
  85. package/dist/runner/codex/index.js +1 -1
  86. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  87. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  88. package/dist/runner/common/acp/protocol-types.d.ts +238 -0
  89. package/dist/runner/common/acp/protocol-types.js +75 -0
  90. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  91. package/dist/runner/common/connection-based-runner.js +261 -0
  92. package/dist/runner/common/jsonrpc/client.d.ts +89 -0
  93. package/dist/runner/common/jsonrpc/client.js +243 -0
  94. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  95. package/dist/runner/common/jsonrpc/connection-manager.js +205 -0
  96. package/dist/runner/common/jsonrpc/transport.d.ts +50 -0
  97. package/dist/runner/common/jsonrpc/transport.js +230 -0
  98. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  99. package/dist/runner/common/jsonrpc/types.js +9 -0
  100. package/dist/runner/common/process-stopper.d.ts +2 -1
  101. package/dist/runner/common/process-stopper.js +1 -0
  102. package/dist/runner/common/runner-utils.d.ts +6 -13
  103. package/dist/runner/common/runner-utils.js +6 -47
  104. package/dist/runner/common/spawning-runner.d.ts +24 -70
  105. package/dist/runner/common/spawning-runner.js +98 -152
  106. package/dist/runner/common/usage.d.ts +2 -0
  107. package/dist/runner/common/usage.js +2 -0
  108. package/dist/runner/index.d.ts +5 -5
  109. package/dist/runner/index.js +4 -4
  110. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  111. package/dist/runner/kimi/acp/runner.js +448 -0
  112. package/dist/runner/kimi/acp/translator.d.ts +124 -0
  113. package/dist/runner/kimi/acp/translator.js +336 -0
  114. package/dist/runner/kimi/index.d.ts +1 -1
  115. package/dist/runner/kimi/index.js +1 -1
  116. package/dist/runner/opencode/acp/index.d.ts +1 -0
  117. package/dist/runner/opencode/acp/index.js +2 -0
  118. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  119. package/dist/runner/opencode/acp/runner.js +429 -0
  120. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  121. package/dist/runner/opencode/acp/translator.js +345 -0
  122. package/dist/runner/opencode/index.d.ts +1 -1
  123. package/dist/runner/opencode/index.js +1 -1
  124. package/dist/runner/pi/index.d.ts +1 -1
  125. package/dist/runner/pi/index.js +1 -1
  126. package/dist/runner/pi/rpc/client.d.ts +52 -0
  127. package/dist/runner/pi/rpc/client.js +114 -0
  128. package/dist/runner/pi/rpc/index.d.ts +4 -0
  129. package/dist/runner/pi/rpc/index.js +4 -0
  130. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  131. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  132. package/dist/runner/pi/rpc/runner.d.ts +80 -0
  133. package/dist/runner/pi/rpc/runner.js +199 -0
  134. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  135. package/dist/runner/pi/rpc/translator.js +258 -0
  136. package/dist/runner/registry.d.ts +7 -8
  137. package/dist/runner/registry.js +3 -4
  138. package/dist/runner/resolve-agent-choices.js +18 -59
  139. package/dist/runner/sync-agent-choices.js +16 -67
  140. package/dist/runner/types.d.ts +153 -2
  141. package/dist/session/claude/session-index.js +2 -3
  142. package/dist/session/claude/session-reader.js +3 -6
  143. package/dist/session/claude/sessions.js +6 -13
  144. package/dist/session/codex/rollout-reader.d.ts +2 -2
  145. package/dist/session/codex/rollout-reader.js +57 -17
  146. package/dist/session/codex/sessions.d.ts +1 -1
  147. package/dist/session/codex/sessions.js +2 -2
  148. package/dist/session/common/content-blocks.d.ts +7 -0
  149. package/dist/session/common/content-blocks.js +13 -9
  150. package/dist/session/common/jsonl.d.ts +8 -25
  151. package/dist/session/common/jsonl.js +126 -261
  152. package/dist/session/common/pagination.d.ts +28 -0
  153. package/dist/session/common/pagination.js +33 -0
  154. package/dist/session/kimi/sessions.d.ts +34 -1
  155. package/dist/session/kimi/sessions.js +208 -98
  156. package/dist/session/opencode/sessions.d.ts +17 -3
  157. package/dist/session/opencode/sessions.js +29 -29
  158. package/dist/session/pi/sessions.d.ts +5 -1
  159. package/dist/session/pi/sessions.js +12 -16
  160. package/dist/session/session-store.d.ts +11 -1
  161. package/dist/session/session-store.js +75 -178
  162. package/dist/update/index.d.ts +2 -2
  163. package/dist/update/index.js +2 -2
  164. package/dist/update/install.d.ts +20 -2
  165. package/dist/update/install.js +51 -7
  166. package/package.json +1 -1
  167. package/dist/runner/codex/argv.d.ts +0 -33
  168. package/dist/runner/codex/argv.js +0 -40
  169. package/dist/runner/codex/jsonl.d.ts +0 -37
  170. package/dist/runner/codex/jsonl.js +0 -221
  171. package/dist/runner/codex/runner.d.ts +0 -63
  172. package/dist/runner/codex/runner.js +0 -124
  173. package/dist/runner/common/exec-translator.d.ts +0 -73
  174. package/dist/runner/common/exec-translator.js +0 -105
  175. package/dist/runner/kimi/jsonl.d.ts +0 -28
  176. package/dist/runner/kimi/jsonl.js +0 -143
  177. package/dist/runner/kimi/runner.d.ts +0 -49
  178. package/dist/runner/kimi/runner.js +0 -105
  179. package/dist/runner/opencode/argv.d.ts +0 -32
  180. package/dist/runner/opencode/argv.js +0 -35
  181. package/dist/runner/opencode/jsonl.d.ts +0 -68
  182. package/dist/runner/opencode/jsonl.js +0 -295
  183. package/dist/runner/opencode/runner.d.ts +0 -61
  184. package/dist/runner/opencode/runner.js +0 -93
  185. package/dist/runner/pi/jsonl.d.ts +0 -128
  186. package/dist/runner/pi/jsonl.js +0 -249
  187. package/dist/runner/pi/runner.d.ts +0 -36
  188. package/dist/runner/pi/runner.js +0 -76
  189. package/dist/session/common/catch-up.d.ts +0 -12
  190. package/dist/session/common/catch-up.js +0 -31
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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
+
30
+ ## [0.1.6] - 2026-08-14
31
+
32
+ ### 新增
33
+
34
+ - Codex App Server 集成:通过 stdio JSON-RPC 持久连接 Codex CLI,支持审批流(文件读写/命令执行)、Compact 上下文压缩、空闲超时自动断开
35
+ - Approval 审批系统:`approval-coordinator` 协调超时/取消/回复,`approval-render` 渲染审批按钮卡片
36
+ - Codex runner 工厂:自动选择直连模式或 App Server 模式
37
+ - `rollout-reader`:Codex App Server rollout 开关读取
38
+ - Run card 审批区域渲染 + context limit 百分比显示
39
+ - 架构文档:`docs/zh/guides/codex-config.md`
40
+
41
+ ### 修复
42
+
43
+ - `/update` 命令移除自动重启逻辑
44
+ - Codex 配置枚举回归官方标准值
45
+
8
46
  ## [0.1.5] - 2026-08-14
9
47
 
10
48
  ### 新增
@@ -136,3 +174,4 @@ Initial release.
136
174
  [0.1.3]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.3
137
175
  [0.1.4]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.4
138
176
  [0.1.5]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.5
177
+ [0.1.6]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.6
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)
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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ApprovalCoordinator: tracks and manages approval requests for a single run.
3
+ *
4
+ * Handles the lifecycle of command/file/permissions approval requests:
5
+ * - onRequested: add to tracking, arm timeout timer
6
+ * - onResolved: update state, clear timer
7
+ * - submit: validate → atomic state transition → async respond
8
+ * - togglePerm: flip permission item selection
9
+ * - onTurnEnded: mark all as expired
10
+ */
11
+ import type { ApprovalRequestedEvent, ApprovalView, AgentEvent } from '../runner/types.js';
12
+ import type { ApprovalAction } from '../runner/types.js';
13
+ type ApprovalState = 'pending' | 'submitting' | 'resolved' | 'failed' | 'expired';
14
+ export interface TrackedApproval {
15
+ requestId: number | string;
16
+ kind: 'command' | 'file' | 'permissions' | 'question';
17
+ state: ApprovalState;
18
+ view: ApprovalView;
19
+ createdAt: number;
20
+ timeoutTimer: ReturnType<typeof setTimeout> | null;
21
+ /** AskUserQuestion 已答问题索引 → 答案 label[](kind === 'question')。 */
22
+ answers?: Map<number, string[]>;
23
+ }
24
+ type ApprovalResponder = (requestId: number | string, response: unknown) => Promise<void>;
25
+ export type { ApprovalAction } from '../runner/types.js';
26
+ export interface ApprovalToggleAction {
27
+ permId: string;
28
+ selected: boolean;
29
+ }
30
+ export declare class ApprovalCoordinator {
31
+ private approvals;
32
+ /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
33
+ private submittedNonces;
34
+ private readonly approvalTimeoutMs;
35
+ private readonly responder;
36
+ private readonly interruptTurn;
37
+ private readonly pushToCard;
38
+ constructor(opts: {
39
+ approvalTimeoutMs: number;
40
+ responder: ApprovalResponder;
41
+ interruptTurn: () => Promise<void>;
42
+ pushToCard: (events: AgentEvent[]) => Promise<void>;
43
+ });
44
+ /**
45
+ * Handle an approval_requested event.
46
+ */
47
+ onRequested(event: ApprovalRequestedEvent): void;
48
+ /**
49
+ * Handle an approval_resolved event.
50
+ */
51
+ onResolved(requestId: number | string): void;
52
+ /**
53
+ * Handle turn ended — mark all pending approvals as expired.
54
+ */
55
+ onTurnEnded(): void;
56
+ /**
57
+ * Submit an approval decision.
58
+ */
59
+ submit(action: ApprovalAction, ctx: {
60
+ requestId: number | string;
61
+ nonce?: string;
62
+ }): Promise<void>;
63
+ /**
64
+ * Toggle a permission item's selected state.
65
+ */
66
+ togglePerm(action: ApprovalToggleAction, ctx: {
67
+ requestId: number | 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>;
104
+ /**
105
+ * Update a pending approval's view (out-of-order item/started arrives late).
106
+ * Only applies while the approval is still pending — resolved/expired
107
+ * approvals must not be resurrected or rewritten.
108
+ */
109
+ updateView(requestId: number | string, view: ApprovalView): void;
110
+ /**
111
+ * Get the number of pending approvals.
112
+ */
113
+ pendingCount(): number;
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;
127
+ private expireApproval;
128
+ private actionToDecision;
129
+ }
130
+ /** Map a card decision string to the ApprovalAction used across the bridge. */
131
+ export declare function decisionToApprovalAction(decision: string): ApprovalAction;
@@ -0,0 +1,409 @@
1
+ /**
2
+ * ApprovalCoordinator: tracks and manages approval requests for a single run.
3
+ *
4
+ * Handles the lifecycle of command/file/permissions approval requests:
5
+ * - onRequested: add to tracking, arm timeout timer
6
+ * - onResolved: update state, clear timer
7
+ * - submit: validate → atomic state transition → async respond
8
+ * - togglePerm: flip permission item selection
9
+ * - onTurnEnded: mark all as expired
10
+ */
11
+ import { getLogger } from '../logger/index.js';
12
+ // =============================================================================
13
+ // Coordinator
14
+ // =============================================================================
15
+ export class ApprovalCoordinator {
16
+ approvals = new Map();
17
+ /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
18
+ submittedNonces = new Map();
19
+ approvalTimeoutMs;
20
+ responder;
21
+ interruptTurn;
22
+ pushToCard;
23
+ constructor(opts) {
24
+ this.approvalTimeoutMs = opts.approvalTimeoutMs;
25
+ this.responder = opts.responder;
26
+ this.interruptTurn = opts.interruptTurn;
27
+ this.pushToCard = opts.pushToCard;
28
+ }
29
+ /**
30
+ * Handle an approval_requested event.
31
+ */
32
+ onRequested(event) {
33
+ const existing = this.approvals.get(event.requestId);
34
+ if (existing) {
35
+ // Already tracked — update state
36
+ existing.state = 'pending';
37
+ existing.view = event.view;
38
+ return;
39
+ }
40
+ const timer = setTimeout(() => {
41
+ this.expireApproval(event.requestId);
42
+ }, this.approvalTimeoutMs);
43
+ const tracked = {
44
+ requestId: event.requestId,
45
+ kind: event.kind,
46
+ state: 'pending',
47
+ view: event.view,
48
+ createdAt: Date.now(),
49
+ timeoutTimer: timer,
50
+ };
51
+ this.approvals.set(event.requestId, tracked);
52
+ }
53
+ /**
54
+ * Handle an approval_resolved event.
55
+ */
56
+ onResolved(requestId) {
57
+ const tracked = this.approvals.get(requestId);
58
+ if (!tracked)
59
+ return;
60
+ tracked.state = 'resolved';
61
+ this.clearTimer(tracked);
62
+ }
63
+ /**
64
+ * Handle turn ended — mark all pending approvals as expired.
65
+ */
66
+ onTurnEnded() {
67
+ for (const [, tracked] of this.approvals) {
68
+ if (tracked.state === 'pending') {
69
+ tracked.state = 'expired';
70
+ this.clearTimer(tracked);
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Submit an approval decision.
76
+ */
77
+ async submit(action, ctx) {
78
+ const tracked = this.approvals.get(ctx.requestId);
79
+ if (!tracked) {
80
+ throw new Error(`Approval request ${ctx.requestId} not found`);
81
+ }
82
+ // nonce 去重:同一张卡片渲染出的按钮连续点击(或飞书重投递)携带相同
83
+ // nonce,只允许生效一次;不同渲染的按钮 nonce 不同,状态机(pending →
84
+ // submitting)仍然兜底防二次提交。
85
+ this.assertFreshNonce(ctx.requestId, ctx.nonce);
86
+ if (tracked.state !== 'pending') {
87
+ throw new Error(`Approval request ${ctx.requestId} is no longer pending (state=${tracked.state})`);
88
+ }
89
+ // Validate action against available decisions. decline/cancel are always
90
+ // allowed as a safety override: denying/interrupting is a universal human
91
+ // affordance even when the server's list omits them (observed in practice).
92
+ const decision = this.actionToDecision(action);
93
+ const safetyOverride = decision === 'decline' || decision === 'cancel';
94
+ if (!tracked.view.availableDecisions.includes(decision) && !safetyOverride) {
95
+ throw new Error(`Decision "${decision}" not in available decisions: ${tracked.view.availableDecisions.join(', ')}`);
96
+ }
97
+ // Atomic state transition: pending → submitting(同步,无 await)
98
+ tracked.state = 'submitting';
99
+ this.clearTimer(tracked);
100
+ // Send response via the responder
101
+ try {
102
+ await this.responder(ctx.requestId, action);
103
+ tracked.state = 'resolved';
104
+ // 提交成功后立即从卡片移除审批区(agent 无关):claude 控制协议没有
105
+ // resolved 回发,不推就会「已点允许按钮仍在」残留 UI;codex 的 server
106
+ // 稍后也会发 approval_resolved,reducer 按 requestId 过滤幂等。
107
+ this.pushResolved(ctx.requestId);
108
+ }
109
+ catch {
110
+ tracked.state = 'failed';
111
+ }
112
+ }
113
+ /**
114
+ * Toggle a permission item's selected state.
115
+ */
116
+ async togglePerm(action, ctx) {
117
+ const tracked = this.approvals.get(ctx.requestId);
118
+ if (!tracked) {
119
+ throw new Error(`Approval request ${ctx.requestId} not found`);
120
+ }
121
+ if (tracked.kind !== 'permissions') {
122
+ throw new Error(`Approval request ${ctx.requestId} is not a permissions request`);
123
+ }
124
+ if (tracked.state !== 'pending') {
125
+ throw new Error(`Approval request ${ctx.requestId} is no longer pending`);
126
+ }
127
+ const items = tracked.view.permissions?.items;
128
+ if (!items) {
129
+ throw new Error(`Approval request ${ctx.requestId} has no permissions items`);
130
+ }
131
+ const item = items.find((i) => i.id === action.permId);
132
+ if (!item) {
133
+ throw new Error(`Permission item ${action.permId} not found in request ${ctx.requestId}`);
134
+ }
135
+ item.selected = action.selected;
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);
226
+ }
227
+ /**
228
+ * Update a pending approval's view (out-of-order item/started arrives late).
229
+ * Only applies while the approval is still pending — resolved/expired
230
+ * approvals must not be resurrected or rewritten.
231
+ */
232
+ updateView(requestId, view) {
233
+ const tracked = this.approvals.get(requestId);
234
+ if (!tracked || tracked.state !== 'pending')
235
+ return;
236
+ tracked.view = view;
237
+ }
238
+ /**
239
+ * Get the number of pending approvals.
240
+ */
241
+ pendingCount() {
242
+ let count = 0;
243
+ for (const [, tracked] of this.approvals) {
244
+ if (tracked.state === 'pending')
245
+ count++;
246
+ }
247
+ return count;
248
+ }
249
+ // =========================================================================
250
+ // Internal
251
+ // =========================================================================
252
+ clearTimer(tracked) {
253
+ if (tracked.timeoutTimer !== null) {
254
+ clearTimeout(tracked.timeoutTimer);
255
+ tracked.timeoutTimer = null;
256
+ }
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
+ }
347
+ expireApproval(requestId) {
348
+ const tracked = this.approvals.get(requestId);
349
+ if (!tracked || tracked.state !== 'pending')
350
+ return;
351
+ tracked.state = 'expired';
352
+ this.clearTimer(tracked);
353
+ // 闭环:审批过期时向 server 发送 cancel(真实协议决策空间中的「停止等待」语义),
354
+ // 避免 server 无限等待审批响应。decline 仅是本桥 UI 安全兜底,不在真实协议内。
355
+ // 此前只改本地状态,server 只能等 runner 的 turn 超时兜底(用户「点了允许还超时」)。
356
+ void this.responder(requestId, { action: 'cancel' }).catch((err) => {
357
+ getLogger().warn(`[approval-coordinator] expiry cancel failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
358
+ // 响应失败不是「正常过期」:状态升级为 failed,避免误判为已正常通知
359
+ // server(spec R1:responder 抛错时状态置 failed)。
360
+ tracked.state = 'failed';
361
+ // 兜底:cancel 未能送达时主动中断 turn,避免 server 无限等待审批响应。
362
+ void this.interruptTurn().catch((interruptErr) => {
363
+ getLogger().warn(`[approval-coordinator] expiry interrupt failed requestId=${requestId}: ${interruptErr instanceof Error ? interruptErr.message : String(interruptErr)}`);
364
+ });
365
+ });
366
+ // 通知卡片进入过期态(run-renderer 渲染「⏰ 审批已过期」并隐藏按钮)。
367
+ void this.pushToCard([{ type: 'approval_expired', requestId }]).catch((err) => {
368
+ getLogger().warn(`[approval-coordinator] expiry card event failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
369
+ });
370
+ }
371
+ actionToDecision(action) {
372
+ switch (action.action) {
373
+ case 'accept':
374
+ return 'accept';
375
+ case 'accept_for_session':
376
+ return 'acceptForSession';
377
+ case 'accept_with_execpolicy_amendment':
378
+ return 'acceptWithExecpolicyAmendment';
379
+ case 'accept_all':
380
+ return 'acceptAll';
381
+ case 'answer':
382
+ return 'answer';
383
+ case 'decline':
384
+ return 'decline';
385
+ case 'cancel':
386
+ return 'cancel';
387
+ }
388
+ }
389
+ }
390
+ /** Map a card decision string to the ApprovalAction used across the bridge. */
391
+ export function decisionToApprovalAction(decision) {
392
+ switch (decision) {
393
+ case 'accept':
394
+ return { action: 'accept' };
395
+ case 'acceptForSession':
396
+ return { action: 'accept_for_session' };
397
+ case 'acceptWithExecpolicyAmendment':
398
+ return { action: 'accept_with_execpolicy_amendment' };
399
+ case 'acceptAll':
400
+ return { action: 'accept_all' };
401
+ case 'decline':
402
+ return { action: 'decline' };
403
+ case 'cancel':
404
+ return { action: 'cancel' };
405
+ default:
406
+ return { action: 'decline' };
407
+ }
408
+ }
409
+ //# sourceMappingURL=approval-coordinator.js.map