leduo-patrol 2.2.1 → 2.2.4

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 (179) hide show
  1. package/README.md +11 -2
  2. package/dist/server/__tests__/acp-session.test.js +115 -0
  3. package/dist/server/__tests__/activity-monitor.test.js +13 -1
  4. package/dist/server/__tests__/session-manager.test.js +380 -1
  5. package/dist/server/acp-session.js +476 -0
  6. package/dist/server/activity-monitor.js +22 -7
  7. package/dist/server/index.js +57 -1
  8. package/dist/server/session-manager.js +1301 -121
  9. package/dist/web/assets/index-Bll9nc_X.js +21 -0
  10. package/dist/web/assets/index-y1qgSOLv.css +1 -0
  11. package/dist/web/index.html +2 -2
  12. package/package.json +3 -1
  13. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/LICENSE +191 -0
  14. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/README.md +53 -0
  15. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +823 -0
  16. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js +965 -0
  17. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -0
  18. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.d.ts +1 -0
  19. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js +839 -0
  20. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js.map +1 -0
  21. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.d.ts +2 -0
  22. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js +225 -0
  23. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js.map +1 -0
  24. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.d.ts +2 -0
  25. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js +130 -0
  26. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js.map +1 -0
  27. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.d.ts +35 -0
  28. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js +5 -0
  29. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js.map +1 -0
  30. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.d.ts +27 -0
  31. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js +28 -0
  32. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js.map +1 -0
  33. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts +2870 -0
  34. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js +3 -0
  35. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js.map +1 -0
  36. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.d.ts +5333 -0
  37. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js +1554 -0
  38. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js.map +1 -0
  39. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +24 -0
  40. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js +64 -0
  41. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -0
  42. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/package.json +66 -0
  43. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/schema/schema.json +4125 -0
  44. package/vendor/claude-code-acp/node_modules/@types/node/LICENSE +21 -0
  45. package/vendor/claude-code-acp/node_modules/@types/node/README.md +15 -0
  46. package/vendor/claude-code-acp/node_modules/@types/node/assert/strict.d.ts +105 -0
  47. package/vendor/claude-code-acp/node_modules/@types/node/assert.d.ts +955 -0
  48. package/vendor/claude-code-acp/node_modules/@types/node/async_hooks.d.ts +623 -0
  49. package/vendor/claude-code-acp/node_modules/@types/node/buffer.buffer.d.ts +466 -0
  50. package/vendor/claude-code-acp/node_modules/@types/node/buffer.d.ts +1810 -0
  51. package/vendor/claude-code-acp/node_modules/@types/node/child_process.d.ts +1428 -0
  52. package/vendor/claude-code-acp/node_modules/@types/node/cluster.d.ts +486 -0
  53. package/vendor/claude-code-acp/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
  54. package/vendor/claude-code-acp/node_modules/@types/node/console.d.ts +151 -0
  55. package/vendor/claude-code-acp/node_modules/@types/node/constants.d.ts +20 -0
  56. package/vendor/claude-code-acp/node_modules/@types/node/crypto.d.ts +4065 -0
  57. package/vendor/claude-code-acp/node_modules/@types/node/dgram.d.ts +564 -0
  58. package/vendor/claude-code-acp/node_modules/@types/node/diagnostics_channel.d.ts +576 -0
  59. package/vendor/claude-code-acp/node_modules/@types/node/dns/promises.d.ts +503 -0
  60. package/vendor/claude-code-acp/node_modules/@types/node/dns.d.ts +922 -0
  61. package/vendor/claude-code-acp/node_modules/@types/node/domain.d.ts +166 -0
  62. package/vendor/claude-code-acp/node_modules/@types/node/events.d.ts +1054 -0
  63. package/vendor/claude-code-acp/node_modules/@types/node/fs/promises.d.ts +1329 -0
  64. package/vendor/claude-code-acp/node_modules/@types/node/fs.d.ts +4676 -0
  65. package/vendor/claude-code-acp/node_modules/@types/node/globals.d.ts +150 -0
  66. package/vendor/claude-code-acp/node_modules/@types/node/globals.typedarray.d.ts +101 -0
  67. package/vendor/claude-code-acp/node_modules/@types/node/http.d.ts +2167 -0
  68. package/vendor/claude-code-acp/node_modules/@types/node/http2.d.ts +2480 -0
  69. package/vendor/claude-code-acp/node_modules/@types/node/https.d.ts +405 -0
  70. package/vendor/claude-code-acp/node_modules/@types/node/index.d.ts +115 -0
  71. package/vendor/claude-code-acp/node_modules/@types/node/inspector/promises.d.ts +41 -0
  72. package/vendor/claude-code-acp/node_modules/@types/node/inspector.d.ts +224 -0
  73. package/vendor/claude-code-acp/node_modules/@types/node/inspector.generated.d.ts +4226 -0
  74. package/vendor/claude-code-acp/node_modules/@types/node/module.d.ts +819 -0
  75. package/vendor/claude-code-acp/node_modules/@types/node/net.d.ts +933 -0
  76. package/vendor/claude-code-acp/node_modules/@types/node/os.d.ts +507 -0
  77. package/vendor/claude-code-acp/node_modules/@types/node/package.json +155 -0
  78. package/vendor/claude-code-acp/node_modules/@types/node/path/posix.d.ts +8 -0
  79. package/vendor/claude-code-acp/node_modules/@types/node/path/win32.d.ts +8 -0
  80. package/vendor/claude-code-acp/node_modules/@types/node/path.d.ts +187 -0
  81. package/vendor/claude-code-acp/node_modules/@types/node/perf_hooks.d.ts +643 -0
  82. package/vendor/claude-code-acp/node_modules/@types/node/process.d.ts +2161 -0
  83. package/vendor/claude-code-acp/node_modules/@types/node/punycode.d.ts +117 -0
  84. package/vendor/claude-code-acp/node_modules/@types/node/querystring.d.ts +152 -0
  85. package/vendor/claude-code-acp/node_modules/@types/node/quic.d.ts +910 -0
  86. package/vendor/claude-code-acp/node_modules/@types/node/readline/promises.d.ts +161 -0
  87. package/vendor/claude-code-acp/node_modules/@types/node/readline.d.ts +541 -0
  88. package/vendor/claude-code-acp/node_modules/@types/node/repl.d.ts +415 -0
  89. package/vendor/claude-code-acp/node_modules/@types/node/sea.d.ts +162 -0
  90. package/vendor/claude-code-acp/node_modules/@types/node/sqlite.d.ts +955 -0
  91. package/vendor/claude-code-acp/node_modules/@types/node/stream/consumers.d.ts +38 -0
  92. package/vendor/claude-code-acp/node_modules/@types/node/stream/promises.d.ts +211 -0
  93. package/vendor/claude-code-acp/node_modules/@types/node/stream/web.d.ts +296 -0
  94. package/vendor/claude-code-acp/node_modules/@types/node/stream.d.ts +1760 -0
  95. package/vendor/claude-code-acp/node_modules/@types/node/string_decoder.d.ts +67 -0
  96. package/vendor/claude-code-acp/node_modules/@types/node/test/reporters.d.ts +96 -0
  97. package/vendor/claude-code-acp/node_modules/@types/node/test.d.ts +2240 -0
  98. package/vendor/claude-code-acp/node_modules/@types/node/timers/promises.d.ts +108 -0
  99. package/vendor/claude-code-acp/node_modules/@types/node/timers.d.ts +159 -0
  100. package/vendor/claude-code-acp/node_modules/@types/node/tls.d.ts +1198 -0
  101. package/vendor/claude-code-acp/node_modules/@types/node/trace_events.d.ts +197 -0
  102. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +462 -0
  103. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +71 -0
  104. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +36 -0
  105. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/index.d.ts +117 -0
  106. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +72 -0
  107. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/index.d.ts +117 -0
  108. package/vendor/claude-code-acp/node_modules/@types/node/tty.d.ts +250 -0
  109. package/vendor/claude-code-acp/node_modules/@types/node/url.d.ts +519 -0
  110. package/vendor/claude-code-acp/node_modules/@types/node/util/types.d.ts +558 -0
  111. package/vendor/claude-code-acp/node_modules/@types/node/util.d.ts +1662 -0
  112. package/vendor/claude-code-acp/node_modules/@types/node/v8.d.ts +983 -0
  113. package/vendor/claude-code-acp/node_modules/@types/node/vm.d.ts +1208 -0
  114. package/vendor/claude-code-acp/node_modules/@types/node/wasi.d.ts +202 -0
  115. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/abortcontroller.d.ts +59 -0
  116. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/blob.d.ts +23 -0
  117. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/console.d.ts +9 -0
  118. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/crypto.d.ts +39 -0
  119. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
  120. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/encoding.d.ts +11 -0
  121. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/events.d.ts +106 -0
  122. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/fetch.d.ts +69 -0
  123. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/importmeta.d.ts +13 -0
  124. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/messaging.d.ts +23 -0
  125. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/navigator.d.ts +25 -0
  126. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/performance.d.ts +45 -0
  127. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/storage.d.ts +24 -0
  128. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/streams.d.ts +115 -0
  129. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/timers.d.ts +44 -0
  130. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/url.d.ts +24 -0
  131. package/vendor/claude-code-acp/node_modules/@types/node/worker_threads.d.ts +717 -0
  132. package/vendor/claude-code-acp/node_modules/@types/node/zlib.d.ts +618 -0
  133. package/vendor/claude-code-acp/node_modules/undici-types/LICENSE +21 -0
  134. package/vendor/claude-code-acp/node_modules/undici-types/README.md +6 -0
  135. package/vendor/claude-code-acp/node_modules/undici-types/agent.d.ts +32 -0
  136. package/vendor/claude-code-acp/node_modules/undici-types/api.d.ts +43 -0
  137. package/vendor/claude-code-acp/node_modules/undici-types/balanced-pool.d.ts +29 -0
  138. package/vendor/claude-code-acp/node_modules/undici-types/cache-interceptor.d.ts +172 -0
  139. package/vendor/claude-code-acp/node_modules/undici-types/cache.d.ts +36 -0
  140. package/vendor/claude-code-acp/node_modules/undici-types/client-stats.d.ts +15 -0
  141. package/vendor/claude-code-acp/node_modules/undici-types/client.d.ts +108 -0
  142. package/vendor/claude-code-acp/node_modules/undici-types/connector.d.ts +34 -0
  143. package/vendor/claude-code-acp/node_modules/undici-types/content-type.d.ts +21 -0
  144. package/vendor/claude-code-acp/node_modules/undici-types/cookies.d.ts +30 -0
  145. package/vendor/claude-code-acp/node_modules/undici-types/diagnostics-channel.d.ts +74 -0
  146. package/vendor/claude-code-acp/node_modules/undici-types/dispatcher.d.ts +276 -0
  147. package/vendor/claude-code-acp/node_modules/undici-types/env-http-proxy-agent.d.ts +22 -0
  148. package/vendor/claude-code-acp/node_modules/undici-types/errors.d.ts +161 -0
  149. package/vendor/claude-code-acp/node_modules/undici-types/eventsource.d.ts +66 -0
  150. package/vendor/claude-code-acp/node_modules/undici-types/fetch.d.ts +211 -0
  151. package/vendor/claude-code-acp/node_modules/undici-types/formdata.d.ts +108 -0
  152. package/vendor/claude-code-acp/node_modules/undici-types/global-dispatcher.d.ts +9 -0
  153. package/vendor/claude-code-acp/node_modules/undici-types/global-origin.d.ts +7 -0
  154. package/vendor/claude-code-acp/node_modules/undici-types/h2c-client.d.ts +73 -0
  155. package/vendor/claude-code-acp/node_modules/undici-types/handlers.d.ts +15 -0
  156. package/vendor/claude-code-acp/node_modules/undici-types/header.d.ts +160 -0
  157. package/vendor/claude-code-acp/node_modules/undici-types/index.d.ts +80 -0
  158. package/vendor/claude-code-acp/node_modules/undici-types/interceptors.d.ts +39 -0
  159. package/vendor/claude-code-acp/node_modules/undici-types/mock-agent.d.ts +68 -0
  160. package/vendor/claude-code-acp/node_modules/undici-types/mock-call-history.d.ts +111 -0
  161. package/vendor/claude-code-acp/node_modules/undici-types/mock-client.d.ts +27 -0
  162. package/vendor/claude-code-acp/node_modules/undici-types/mock-errors.d.ts +12 -0
  163. package/vendor/claude-code-acp/node_modules/undici-types/mock-interceptor.d.ts +94 -0
  164. package/vendor/claude-code-acp/node_modules/undici-types/mock-pool.d.ts +27 -0
  165. package/vendor/claude-code-acp/node_modules/undici-types/package.json +55 -0
  166. package/vendor/claude-code-acp/node_modules/undici-types/patch.d.ts +29 -0
  167. package/vendor/claude-code-acp/node_modules/undici-types/pool-stats.d.ts +19 -0
  168. package/vendor/claude-code-acp/node_modules/undici-types/pool.d.ts +41 -0
  169. package/vendor/claude-code-acp/node_modules/undici-types/proxy-agent.d.ts +29 -0
  170. package/vendor/claude-code-acp/node_modules/undici-types/readable.d.ts +68 -0
  171. package/vendor/claude-code-acp/node_modules/undici-types/retry-agent.d.ts +8 -0
  172. package/vendor/claude-code-acp/node_modules/undici-types/retry-handler.d.ts +125 -0
  173. package/vendor/claude-code-acp/node_modules/undici-types/snapshot-agent.d.ts +109 -0
  174. package/vendor/claude-code-acp/node_modules/undici-types/util.d.ts +18 -0
  175. package/vendor/claude-code-acp/node_modules/undici-types/utility.d.ts +7 -0
  176. package/vendor/claude-code-acp/node_modules/undici-types/webidl.d.ts +341 -0
  177. package/vendor/claude-code-acp/node_modules/undici-types/websocket.d.ts +186 -0
  178. package/dist/web/assets/index-B5Dh2E8j.css +0 -1
  179. package/dist/web/assets/index-xPPPaEde.js +0 -13
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # 乐多汪汪队 / leduo-patrol
2
2
 
3
- 一个部署在服务器上的 Web 控制台,用来通过 ACP 驱动 Claude Code,并在浏览器里接收执行流、工具调用和权限确认。
3
+ 一个部署在服务器上的 Web 控制台,可在浏览器里用两种方式连接 Claude Code
4
+ - `CLI`:直接内嵌 Claude Code 终端
5
+ - `ACP`:结构化时间线 / 权限 / 提问 / 图片输入视图
6
+
7
+ 两种引擎可在同一会话内切换,并共用同一个 Claude `sessionId`。
4
8
 
5
9
  ## Showcase
6
10
 
@@ -83,7 +87,10 @@
83
87
  - **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
84
88
 
85
89
  ### 工具与集成
86
- - **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY 终端体验;服务端默认开启,可通过 `LEDUO_ENABLE_SHELL=false` 显式关闭
90
+ - **双引擎会话**:同一 workspace 会话可在 `CLI` `ACP` 之间切换;切换时复用 Claude `sessionId`
91
+ - **CLI 终端**:主视图可直接运行 Claude Code 原生终端
92
+ - **ACP 结构化视图**:支持时间线、权限确认、AskUserQuestion、多模态图片输入
93
+ - **内置 Shell**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY shell;服务端默认开启,可通过 `LEDUO_ENABLE_SHELL=false` 显式关闭
87
94
 
88
95
  ### 界面与可访问性
89
96
  - **访问 Key 认证**:所有请求(HTTP / WebSocket)均需携带 key;浏览器检测到无效 key 时展示输入页
@@ -151,6 +158,7 @@ LEDUO_PATROL_APP_NAME=乐多汪汪队
151
158
  LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
152
159
  LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
153
160
  LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
161
+ LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
154
162
  LEDUO_PATROL_SHELL=/absolute/path/to/zsh
155
163
  ANTHROPIC_API_KEY=sk-...
156
164
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
@@ -161,6 +169,7 @@ LEDUO_ENABLE_SHELL=false
161
169
  如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
162
170
  如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
163
171
  如果发布安装后的内嵌终端无法启动,可通过 `LEDUO_PATROL_SHELL` 显式指定 shell 路径;例如 macOS 上常见的 `/bin/zsh`。
172
+ 如果 ACP agent 不在默认安装位置,可通过 `LEDUO_PATROL_AGENT_BIN` 显式指定 `claude-code-acp` 可执行文件。
164
173
 
165
174
  ## 状态持久化
166
175
 
@@ -0,0 +1,115 @@
1
+ import test, { mock } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { PassThrough } from "node:stream";
5
+ import { ClaudeAcpSession, acpSessionTestables } from "../acp-session.js";
6
+ function makeSession() {
7
+ return new ClaudeAcpSession({
8
+ workspacePath: "/tmp/workspace",
9
+ agentBinPath: "claude-code-acp",
10
+ claudeBin: "/tmp/custom-claude",
11
+ onEvent: () => undefined,
12
+ });
13
+ }
14
+ test("ClaudeAcpSession.resolveWorkspacePath allows path within workspace", () => {
15
+ const session = makeSession();
16
+ const resolved = session.resolveWorkspacePath("a/b.txt");
17
+ assert.equal(resolved, "/tmp/workspace/a/b.txt");
18
+ });
19
+ test("ClaudeAcpSession.resolveWorkspacePath rejects traversal", () => {
20
+ const session = makeSession();
21
+ assert.throws(() => session.resolveWorkspacePath("../etc/passwd"), /outside workspace/);
22
+ });
23
+ test("ClaudeAcpSession.resolvePermission rejects unknown request", async () => {
24
+ const session = makeSession();
25
+ await assert.rejects(() => session.resolvePermission("missing", "allow"), /not found|already resolved/);
26
+ });
27
+ test("ClaudeAcpSession.answerQuestion resolves pending question", async () => {
28
+ const session = makeSession();
29
+ const calls = [];
30
+ session.pendingQuestions.set("q-1", {
31
+ resolve: (value) => calls.push(value),
32
+ reject: () => undefined,
33
+ });
34
+ await session.answerQuestion("q-1", "好的");
35
+ assert.deepEqual(calls[0], { answer: "好的" });
36
+ assert.equal(session.pendingQuestions.size, 0);
37
+ });
38
+ test("ClaudeAcpSession.handleExtMethod routes leduo/ask_question", async () => {
39
+ const events = [];
40
+ const session = new ClaudeAcpSession({
41
+ workspacePath: "/tmp/workspace",
42
+ agentBinPath: "claude-code-acp",
43
+ onEvent: (event) => events.push(event),
44
+ });
45
+ const resultPromise = session.handleExtMethod("leduo/ask_question", {
46
+ question: "选择颜色",
47
+ options: [{ id: "red", label: "红色" }],
48
+ allowCustomAnswer: true,
49
+ });
50
+ const event = events[0];
51
+ assert.equal(event.type, "question_requested");
52
+ await session.answerQuestion(event.payload.questionId, "红色");
53
+ const result = await resultPromise;
54
+ assert.deepEqual(result, { answer: "红色" });
55
+ });
56
+ test("ClaudeAcpSession.findRestorableSession requires exact match when preferred id is provided", async () => {
57
+ const session = makeSession();
58
+ session.connection = {
59
+ unstable_listSessions: async () => ({
60
+ sessions: [{ sessionId: "other-session-id" }],
61
+ }),
62
+ };
63
+ const result = await session.findRestorableSession("missing-session-id");
64
+ assert.equal(result, null);
65
+ });
66
+ test("ClaudeAcpSession.findRestorableSession falls back to the first session when no preferred id is provided", async () => {
67
+ const session = makeSession();
68
+ session.connection = {
69
+ unstable_listSessions: async () => ({
70
+ sessions: [{ sessionId: "restorable-session-id" }],
71
+ }),
72
+ };
73
+ const result = await session.findRestorableSession();
74
+ assert.equal(result, "restorable-session-id");
75
+ });
76
+ test("ClaudeAcpSession.prompt emits prompt_started with images", async () => {
77
+ const events = [];
78
+ const session = new ClaudeAcpSession({
79
+ workspacePath: "/tmp/workspace",
80
+ agentBinPath: "claude-code-acp",
81
+ onEvent: (event) => events.push(event),
82
+ });
83
+ let promptPayload = null;
84
+ session.connection = {
85
+ prompt: async (payload) => {
86
+ promptPayload = payload;
87
+ return { stopReason: "end_turn" };
88
+ },
89
+ };
90
+ session.sessionId = "session-1";
91
+ session.sessionPromise = Promise.resolve("session-1");
92
+ session.waitForDrain = async () => undefined;
93
+ await session.prompt("hello", [{ data: "abc", mimeType: "image/png" }]);
94
+ const started = events[0];
95
+ assert.equal(started.type, "prompt_started");
96
+ assert.deepEqual(started.payload.images, [{ data: "abc", mimeType: "image/png" }]);
97
+ assert.deepEqual(promptPayload.prompt.map((block) => block.type), ["image", "text"]);
98
+ });
99
+ test("ClaudeAcpSession.connect rejects gracefully when the ACP agent spawn emits EAGAIN", async (t) => {
100
+ const fakeChild = new EventEmitter();
101
+ fakeChild.stdin = new PassThrough();
102
+ fakeChild.stdout = new PassThrough();
103
+ fakeChild.stderr = new PassThrough();
104
+ fakeChild.kill = (() => true);
105
+ const spawnMock = mock.method(acpSessionTestables, "spawnAgent", () => {
106
+ queueMicrotask(() => {
107
+ const error = Object.assign(new Error("resource temporarily unavailable"), { code: "EAGAIN" });
108
+ fakeChild.emit("error", error);
109
+ });
110
+ return fakeChild;
111
+ });
112
+ t.after(() => spawnMock.mock.restore());
113
+ const session = makeSession();
114
+ await assert.rejects(() => session.connect(), /Failed to start Claude ACP agent.*EAGAIN/);
115
+ });
@@ -8,12 +8,24 @@ test("assistant with stop_reason null → running", () => {
8
8
  message: { stop_reason: null, content: [{ type: "thinking" }] },
9
9
  }), "running");
10
10
  });
11
- test("assistant with stop_reason undefined → running", () => {
11
+ test("assistant with text content and stop_reason undefined → completed", () => {
12
12
  assert.equal(determineActivityState({
13
13
  type: "assistant",
14
14
  message: { content: [{ type: "text" }] },
15
+ }), "completed");
16
+ });
17
+ test("assistant with thinking content and stop_reason undefined → running", () => {
18
+ assert.equal(determineActivityState({
19
+ type: "assistant",
20
+ message: { content: [{ type: "thinking" }] },
15
21
  }), "running");
16
22
  });
23
+ test("assistant with tool_use content and stop_reason undefined → pending", () => {
24
+ assert.equal(determineActivityState({
25
+ type: "assistant",
26
+ message: { content: [{ type: "tool_use" }] },
27
+ }), "pending");
28
+ });
17
29
  test("assistant with no message field → running", () => {
18
30
  assert.equal(determineActivityState({ type: "assistant" }), "running");
19
31
  });
@@ -1,7 +1,386 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { sessionManagerTestables } from "../session-manager.js";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp, writeFile } from "node:fs/promises";
6
+ import { SessionManager, sessionManagerTestables } from "../session-manager.js";
7
+ function makeSnapshot(overrides = {}) {
8
+ return {
9
+ clientSessionId: "s1",
10
+ title: "demo",
11
+ workspacePath: process.cwd(),
12
+ connectionState: "connected",
13
+ activityState: "idle",
14
+ sessionId: "shared-session-id",
15
+ engine: "cli",
16
+ switchable: true,
17
+ updatedAt: new Date().toISOString(),
18
+ ...overrides,
19
+ };
20
+ }
21
+ function makeEntry(overrides = {}) {
22
+ return {
23
+ snapshot: makeSnapshot(overrides),
24
+ cliSession: null,
25
+ cliExitExpected: false,
26
+ acpSession: null,
27
+ acpFullTimeline: [],
28
+ outputBuffer: "",
29
+ switchInProgress: false,
30
+ acpQueueDrainActive: false,
31
+ };
32
+ }
33
+ function makeAcpState(overrides = {}) {
34
+ return {
35
+ modes: ["default"],
36
+ defaultModeId: "default",
37
+ currentModeId: "default",
38
+ busy: false,
39
+ timeline: [],
40
+ historyTotal: 0,
41
+ historyStart: 0,
42
+ permissions: [],
43
+ questions: [],
44
+ availableCommands: [],
45
+ queuedPrompts: [],
46
+ ...overrides,
47
+ };
48
+ }
49
+ function makeManager(options) {
50
+ const manager = new SessionManager(options);
51
+ manager.activityMonitor.watch = () => undefined;
52
+ manager.activityMonitor.unwatch = () => undefined;
53
+ return manager;
54
+ }
4
55
  test("sessionManagerTestables.formatError handles Error and objects", () => {
5
56
  assert.equal(sessionManagerTestables.formatError(new Error("boom")), "boom");
6
57
  assert.match(sessionManagerTestables.formatError({ code: 1 }), /"code":1/);
7
58
  });
59
+ test("SessionManager.getAvailableEngines exposes ACP only when agent path exists", () => {
60
+ const cliOnly = makeManager({ allowedRoots: [process.cwd()] });
61
+ const dual = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
62
+ assert.deepEqual(cliOnly.getAvailableEngines(), ["cli"]);
63
+ assert.deepEqual(dual.getAvailableEngines(), ["cli", "acp"]);
64
+ });
65
+ test("SessionManager.switchEngine keeps the same Claude sessionId", async () => {
66
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
67
+ const started = [];
68
+ const stopped = [];
69
+ const events = [];
70
+ manager.startEngine = async (entry, resume) => {
71
+ started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
72
+ entry.snapshot.connectionState = "connected";
73
+ };
74
+ manager.stopEngine = async (entry) => {
75
+ stopped.push(entry.snapshot.engine);
76
+ };
77
+ manager.subscribe((event) => events.push(event.type));
78
+ manager.sessions.set("s1", makeEntry());
79
+ await manager.switchEngine("s1", "acp");
80
+ const entry = manager.sessions.get("s1");
81
+ assert.equal(entry.snapshot.engine, "acp");
82
+ assert.equal(entry.snapshot.sessionId, "shared-session-id");
83
+ assert.deepEqual(stopped, ["cli"]);
84
+ assert.deepEqual(started, [{ engine: "acp", resume: true, sessionId: "shared-session-id" }]);
85
+ assert.equal(events.at(-1), "session_updated");
86
+ });
87
+ test("SessionManager.switchEngine clears buffered CLI output before starting the target engine", async () => {
88
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
89
+ manager.sessions.set("s1", {
90
+ ...makeEntry({ engine: "acp" }),
91
+ outputBuffer: "old cli output",
92
+ });
93
+ manager.startEngine = async (entry, resume) => {
94
+ assert.equal(entry.outputBuffer, "");
95
+ assert.equal(resume, true);
96
+ entry.snapshot.connectionState = "connected";
97
+ };
98
+ manager.stopEngine = SessionManager.prototype["stopEngine"].bind(manager);
99
+ await manager.switchEngine("s1", "cli");
100
+ const entry = manager.sessions.get("s1");
101
+ assert.equal(entry.outputBuffer, "");
102
+ });
103
+ test("SessionManager.createSession starts fresh CLI sessions and emits a connected snapshot", async () => {
104
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
105
+ const started = [];
106
+ const events = [];
107
+ manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
108
+ manager.startEngine = async (entry, resume) => {
109
+ started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
110
+ entry.snapshot.connectionState = "connected";
111
+ };
112
+ manager.subscribe((event) => {
113
+ if (event.type === "session_registered" || event.type === "session_updated") {
114
+ events.push(event);
115
+ }
116
+ });
117
+ const snapshot = await manager.createSession("/tmp/fresh-cli-workspace", "fresh-cli", false, "cli");
118
+ assert.equal(snapshot.engine, "cli");
119
+ assert.ok(snapshot.sessionId);
120
+ assert.deepEqual(started, [{ engine: "cli", resume: false, sessionId: snapshot.sessionId }]);
121
+ assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
122
+ assert.equal(events[0]?.payload.connectionState, "connecting");
123
+ assert.equal(events[1]?.payload.connectionState, "connected");
124
+ });
125
+ test("SessionManager.createSession emits an error snapshot when startup fails", async () => {
126
+ const manager = makeManager({ allowedRoots: [process.cwd()] });
127
+ const events = [];
128
+ manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
129
+ manager.startEngine = async (entry) => {
130
+ entry.snapshot.connectionState = "error";
131
+ throw new Error("boom");
132
+ };
133
+ manager.subscribe((event) => {
134
+ if (event.type === "session_registered" || event.type === "session_updated") {
135
+ events.push(event);
136
+ }
137
+ });
138
+ await assert.rejects(() => manager.createSession("/tmp/broken-cli-workspace", "broken-cli", false, "cli"), /boom/);
139
+ assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
140
+ assert.equal(events[0]?.payload.connectionState, "connecting");
141
+ assert.equal(events[1]?.payload.connectionState, "error");
142
+ });
143
+ test("SessionManager.switchEngine rejects busy sessions", async () => {
144
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
145
+ manager.sessions.set("s1", makeEntry({ activityState: "running" }));
146
+ manager.startEngine = async () => undefined;
147
+ manager.stopEngine = async () => undefined;
148
+ await assert.rejects(() => manager.switchEngine("s1", "acp"), /Session is not switchable: 运行中/);
149
+ });
150
+ test("SessionManager.switchEngine rejects pending ACP approvals", async () => {
151
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
152
+ manager.sessions.set("s1", makeEntry({
153
+ engine: "acp",
154
+ acp: {
155
+ modes: ["default"],
156
+ defaultModeId: "default",
157
+ currentModeId: "default",
158
+ busy: false,
159
+ timeline: [],
160
+ historyTotal: 0,
161
+ historyStart: 0,
162
+ permissions: [{
163
+ clientSessionId: "s1",
164
+ requestId: "req-1",
165
+ toolCall: { toolCallId: "tool-1", title: "Write" },
166
+ options: [{ optionId: "allow", name: "允许", kind: "allow" }],
167
+ }],
168
+ questions: [],
169
+ availableCommands: [],
170
+ queuedPrompts: [],
171
+ },
172
+ }));
173
+ manager.startEngine = async () => undefined;
174
+ manager.stopEngine = async () => undefined;
175
+ await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 待审批/);
176
+ });
177
+ test("SessionManager.switchEngine allows idle ACP sessions despite stale activityState", async () => {
178
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
179
+ const started = [];
180
+ const stopped = [];
181
+ manager.sessions.set("s1", makeEntry({
182
+ engine: "acp",
183
+ activityState: "running",
184
+ acp: {
185
+ modes: ["default"],
186
+ defaultModeId: "default",
187
+ currentModeId: "default",
188
+ busy: false,
189
+ timeline: [],
190
+ historyTotal: 0,
191
+ historyStart: 0,
192
+ permissions: [],
193
+ questions: [],
194
+ availableCommands: [],
195
+ queuedPrompts: [],
196
+ },
197
+ }));
198
+ manager.startEngine = async (entry, resume) => {
199
+ started.push({ engine: entry.snapshot.engine, resume });
200
+ entry.snapshot.connectionState = "connected";
201
+ };
202
+ manager.stopEngine = async (entry) => {
203
+ stopped.push(entry.snapshot.engine);
204
+ };
205
+ await manager.switchEngine("s1", "cli");
206
+ assert.deepEqual(stopped, ["acp"]);
207
+ assert.deepEqual(started, [{ engine: "cli", resume: true }]);
208
+ });
209
+ test("SessionManager.switchEngine allows ACP sessions after end_turn even if busy flag is stale", async () => {
210
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
211
+ const started = [];
212
+ const stopped = [];
213
+ manager.sessions.set("s1", makeEntry({
214
+ engine: "acp",
215
+ acp: {
216
+ modes: ["default"],
217
+ defaultModeId: "default",
218
+ currentModeId: "default",
219
+ busy: true,
220
+ timeline: [{
221
+ id: "done-1",
222
+ kind: "system",
223
+ title: "本轮完成",
224
+ body: "end_turn",
225
+ }],
226
+ historyTotal: 1,
227
+ historyStart: 0,
228
+ permissions: [],
229
+ questions: [],
230
+ availableCommands: [],
231
+ queuedPrompts: [],
232
+ },
233
+ }));
234
+ manager.startEngine = async (entry, resume) => {
235
+ started.push({ engine: entry.snapshot.engine, resume });
236
+ entry.snapshot.connectionState = "connected";
237
+ };
238
+ manager.stopEngine = async (entry) => {
239
+ stopped.push(entry.snapshot.engine);
240
+ };
241
+ await manager.switchEngine("s1", "cli");
242
+ assert.deepEqual(stopped, ["acp"]);
243
+ assert.deepEqual(started, [{ engine: "cli", resume: true }]);
244
+ });
245
+ test("SessionManager.prompt queues ACP prompts while busy", async () => {
246
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
247
+ const promptCalls = [];
248
+ manager.sessions.set("s1", {
249
+ ...makeEntry({
250
+ engine: "acp",
251
+ acp: makeAcpState({ busy: true }),
252
+ }),
253
+ acpSession: {
254
+ setMode: async () => undefined,
255
+ prompt: async () => {
256
+ promptCalls.push("prompt");
257
+ },
258
+ },
259
+ });
260
+ await manager.prompt("s1", "queued hello");
261
+ const entry = manager.sessions.get("s1");
262
+ assert.equal(promptCalls.length, 0);
263
+ assert.equal(entry.snapshot.acp?.queuedPrompts.length, 1);
264
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.text, "queued hello");
265
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.status, "queued");
266
+ assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.modeId, "default");
267
+ });
268
+ test("SessionManager.prompt sends immediately when ACP session is idle", async () => {
269
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
270
+ const setModeCalls = [];
271
+ const promptCalls = [];
272
+ manager.sessions.set("s1", {
273
+ ...makeEntry({
274
+ engine: "acp",
275
+ acp: makeAcpState(),
276
+ }),
277
+ acpSession: {
278
+ setMode: async (modeId) => {
279
+ setModeCalls.push(modeId);
280
+ },
281
+ prompt: async (text, images) => {
282
+ promptCalls.push({ text, images });
283
+ },
284
+ },
285
+ });
286
+ await manager.prompt("s1", "ship it", "plan", [{ data: "base64", mimeType: "image/png" }]);
287
+ const entry = manager.sessions.get("s1");
288
+ assert.deepEqual(setModeCalls, ["plan"]);
289
+ assert.deepEqual(promptCalls, [{ text: "ship it", images: [{ data: "base64", mimeType: "image/png" }] }]);
290
+ assert.equal(entry.snapshot.acp?.queuedPrompts.length, 0);
291
+ });
292
+ test("SessionManager.prompt keeps strict FIFO when queued prompts already exist", async () => {
293
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
294
+ const promptCalls = [];
295
+ manager.sessions.set("s1", {
296
+ ...makeEntry({
297
+ engine: "acp",
298
+ acp: makeAcpState({
299
+ queuedPrompts: [{
300
+ id: "queued-1",
301
+ text: "first",
302
+ images: [],
303
+ modeId: "default",
304
+ createdAt: new Date().toISOString(),
305
+ status: "queued",
306
+ }],
307
+ }),
308
+ }),
309
+ acpSession: {
310
+ setMode: async () => undefined,
311
+ prompt: async () => {
312
+ promptCalls.push("prompt");
313
+ },
314
+ },
315
+ });
316
+ await manager.prompt("s1", "second");
317
+ const queuedPrompts = manager.sessions.get("s1").snapshot.acp?.queuedPrompts ?? [];
318
+ assert.equal(promptCalls.length, 1);
319
+ assert.deepEqual(queuedPrompts.map((prompt) => prompt.text), ["first", "second"]);
320
+ assert.deepEqual(queuedPrompts.map((prompt) => prompt.status), ["sending", "queued"]);
321
+ });
322
+ test("SessionManager.switchEngine rejects ACP sessions with queued prompts", async () => {
323
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
324
+ manager.sessions.set("s1", makeEntry({
325
+ engine: "acp",
326
+ acp: makeAcpState({
327
+ queuedPrompts: [{
328
+ id: "queued-1",
329
+ text: "first",
330
+ images: [],
331
+ modeId: "default",
332
+ createdAt: new Date().toISOString(),
333
+ status: "queued",
334
+ }],
335
+ }),
336
+ }));
337
+ await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 队列未清空/);
338
+ });
339
+ test("SessionManager recovers persisted ACP queues and drains them on initialize", async () => {
340
+ const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
341
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "leduo-patrol-session-manager-"));
342
+ const stateFilePath = path.join(tempDir, "state.json");
343
+ const promptCalls = [];
344
+ const setModeCalls = [];
345
+ await writeFile(stateFilePath, JSON.stringify({
346
+ sessions: [{
347
+ clientSessionId: "persisted-acp",
348
+ title: "persisted",
349
+ workspacePath: process.cwd(),
350
+ sessionId: "shared-session-id",
351
+ engine: "acp",
352
+ updatedAt: new Date().toISOString(),
353
+ acpDefaultModeId: "default",
354
+ acpCurrentModeId: "default",
355
+ acpQueuedPrompts: [{
356
+ id: "queued-1",
357
+ text: "resume me",
358
+ images: [{ data: "abc", mimeType: "image/png" }],
359
+ modeId: "plan",
360
+ createdAt: new Date().toISOString(),
361
+ status: "sending",
362
+ }],
363
+ }],
364
+ }), "utf8");
365
+ manager.stateFilePath = stateFilePath;
366
+ manager.isRestorableWorkspace = async () => true;
367
+ manager.startHistoryMonitor = async () => undefined;
368
+ manager.startEngine = async (entry) => {
369
+ entry.snapshot.connectionState = "connected";
370
+ entry.acpSession = {
371
+ setMode: async (modeId) => {
372
+ setModeCalls.push(modeId);
373
+ },
374
+ prompt: async (text, images) => {
375
+ promptCalls.push({ text, images });
376
+ },
377
+ };
378
+ manager.recoverSendingQueuedPrompts(entry);
379
+ await manager.drainAcpPromptQueue(entry);
380
+ };
381
+ await manager.initialize();
382
+ const restoredEntry = manager.sessions.get("persisted-acp");
383
+ assert.deepEqual(setModeCalls, ["plan"]);
384
+ assert.deepEqual(promptCalls, [{ text: "resume me", images: [{ data: "abc", mimeType: "image/png" }] }]);
385
+ assert.equal(restoredEntry.snapshot.acp?.queuedPrompts[0]?.status, "sending");
386
+ });