leduo-patrol 2.0.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +27 -4
  2. package/dist/server/__tests__/access-key-prompt.test.js +80 -0
  3. package/dist/server/__tests__/acp-session.test.js +92 -0
  4. package/dist/server/__tests__/activity-monitor.test.js +13 -1
  5. package/dist/server/__tests__/claude-cli-session.test.js +17 -0
  6. package/dist/server/__tests__/pty-runtime.test.js +28 -0
  7. package/dist/server/__tests__/session-manager.test.js +215 -1
  8. package/dist/server/access-key-prompt.js +84 -0
  9. package/dist/server/acp-session.js +476 -0
  10. package/dist/server/activity-monitor.js +22 -7
  11. package/dist/server/claude-cli-session.js +57 -12
  12. package/dist/server/index.js +104 -21
  13. package/dist/server/launch-mode.js +4 -22
  14. package/dist/server/pty-runtime.js +28 -0
  15. package/dist/server/session-manager.js +1117 -121
  16. package/dist/server/shell-session.js +2 -0
  17. package/dist/server/startup-preferences.js +45 -0
  18. package/dist/web/assets/index-B-YXVUoQ.css +1 -0
  19. package/dist/web/assets/index-Bu0K7QgY.js +21 -0
  20. package/dist/web/index.html +2 -2
  21. package/package.json +3 -1
  22. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/LICENSE +191 -0
  23. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/README.md +53 -0
  24. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +823 -0
  25. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js +965 -0
  26. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -0
  27. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.d.ts +1 -0
  28. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js +839 -0
  29. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js.map +1 -0
  30. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.d.ts +2 -0
  31. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js +225 -0
  32. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js.map +1 -0
  33. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.d.ts +2 -0
  34. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js +130 -0
  35. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js.map +1 -0
  36. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.d.ts +35 -0
  37. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js +5 -0
  38. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js.map +1 -0
  39. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.d.ts +27 -0
  40. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js +28 -0
  41. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js.map +1 -0
  42. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts +2870 -0
  43. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js +3 -0
  44. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js.map +1 -0
  45. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.d.ts +5333 -0
  46. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js +1554 -0
  47. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js.map +1 -0
  48. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +24 -0
  49. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js +64 -0
  50. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -0
  51. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/package.json +66 -0
  52. package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/schema/schema.json +4125 -0
  53. package/vendor/claude-code-acp/node_modules/@types/node/LICENSE +21 -0
  54. package/vendor/claude-code-acp/node_modules/@types/node/README.md +15 -0
  55. package/vendor/claude-code-acp/node_modules/@types/node/assert/strict.d.ts +105 -0
  56. package/vendor/claude-code-acp/node_modules/@types/node/assert.d.ts +955 -0
  57. package/vendor/claude-code-acp/node_modules/@types/node/async_hooks.d.ts +623 -0
  58. package/vendor/claude-code-acp/node_modules/@types/node/buffer.buffer.d.ts +466 -0
  59. package/vendor/claude-code-acp/node_modules/@types/node/buffer.d.ts +1810 -0
  60. package/vendor/claude-code-acp/node_modules/@types/node/child_process.d.ts +1428 -0
  61. package/vendor/claude-code-acp/node_modules/@types/node/cluster.d.ts +486 -0
  62. package/vendor/claude-code-acp/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
  63. package/vendor/claude-code-acp/node_modules/@types/node/console.d.ts +151 -0
  64. package/vendor/claude-code-acp/node_modules/@types/node/constants.d.ts +20 -0
  65. package/vendor/claude-code-acp/node_modules/@types/node/crypto.d.ts +4065 -0
  66. package/vendor/claude-code-acp/node_modules/@types/node/dgram.d.ts +564 -0
  67. package/vendor/claude-code-acp/node_modules/@types/node/diagnostics_channel.d.ts +576 -0
  68. package/vendor/claude-code-acp/node_modules/@types/node/dns/promises.d.ts +503 -0
  69. package/vendor/claude-code-acp/node_modules/@types/node/dns.d.ts +922 -0
  70. package/vendor/claude-code-acp/node_modules/@types/node/domain.d.ts +166 -0
  71. package/vendor/claude-code-acp/node_modules/@types/node/events.d.ts +1054 -0
  72. package/vendor/claude-code-acp/node_modules/@types/node/fs/promises.d.ts +1329 -0
  73. package/vendor/claude-code-acp/node_modules/@types/node/fs.d.ts +4676 -0
  74. package/vendor/claude-code-acp/node_modules/@types/node/globals.d.ts +150 -0
  75. package/vendor/claude-code-acp/node_modules/@types/node/globals.typedarray.d.ts +101 -0
  76. package/vendor/claude-code-acp/node_modules/@types/node/http.d.ts +2167 -0
  77. package/vendor/claude-code-acp/node_modules/@types/node/http2.d.ts +2480 -0
  78. package/vendor/claude-code-acp/node_modules/@types/node/https.d.ts +405 -0
  79. package/vendor/claude-code-acp/node_modules/@types/node/index.d.ts +115 -0
  80. package/vendor/claude-code-acp/node_modules/@types/node/inspector/promises.d.ts +41 -0
  81. package/vendor/claude-code-acp/node_modules/@types/node/inspector.d.ts +224 -0
  82. package/vendor/claude-code-acp/node_modules/@types/node/inspector.generated.d.ts +4226 -0
  83. package/vendor/claude-code-acp/node_modules/@types/node/module.d.ts +819 -0
  84. package/vendor/claude-code-acp/node_modules/@types/node/net.d.ts +933 -0
  85. package/vendor/claude-code-acp/node_modules/@types/node/os.d.ts +507 -0
  86. package/vendor/claude-code-acp/node_modules/@types/node/package.json +155 -0
  87. package/vendor/claude-code-acp/node_modules/@types/node/path/posix.d.ts +8 -0
  88. package/vendor/claude-code-acp/node_modules/@types/node/path/win32.d.ts +8 -0
  89. package/vendor/claude-code-acp/node_modules/@types/node/path.d.ts +187 -0
  90. package/vendor/claude-code-acp/node_modules/@types/node/perf_hooks.d.ts +643 -0
  91. package/vendor/claude-code-acp/node_modules/@types/node/process.d.ts +2161 -0
  92. package/vendor/claude-code-acp/node_modules/@types/node/punycode.d.ts +117 -0
  93. package/vendor/claude-code-acp/node_modules/@types/node/querystring.d.ts +152 -0
  94. package/vendor/claude-code-acp/node_modules/@types/node/quic.d.ts +910 -0
  95. package/vendor/claude-code-acp/node_modules/@types/node/readline/promises.d.ts +161 -0
  96. package/vendor/claude-code-acp/node_modules/@types/node/readline.d.ts +541 -0
  97. package/vendor/claude-code-acp/node_modules/@types/node/repl.d.ts +415 -0
  98. package/vendor/claude-code-acp/node_modules/@types/node/sea.d.ts +162 -0
  99. package/vendor/claude-code-acp/node_modules/@types/node/sqlite.d.ts +955 -0
  100. package/vendor/claude-code-acp/node_modules/@types/node/stream/consumers.d.ts +38 -0
  101. package/vendor/claude-code-acp/node_modules/@types/node/stream/promises.d.ts +211 -0
  102. package/vendor/claude-code-acp/node_modules/@types/node/stream/web.d.ts +296 -0
  103. package/vendor/claude-code-acp/node_modules/@types/node/stream.d.ts +1760 -0
  104. package/vendor/claude-code-acp/node_modules/@types/node/string_decoder.d.ts +67 -0
  105. package/vendor/claude-code-acp/node_modules/@types/node/test/reporters.d.ts +96 -0
  106. package/vendor/claude-code-acp/node_modules/@types/node/test.d.ts +2240 -0
  107. package/vendor/claude-code-acp/node_modules/@types/node/timers/promises.d.ts +108 -0
  108. package/vendor/claude-code-acp/node_modules/@types/node/timers.d.ts +159 -0
  109. package/vendor/claude-code-acp/node_modules/@types/node/tls.d.ts +1198 -0
  110. package/vendor/claude-code-acp/node_modules/@types/node/trace_events.d.ts +197 -0
  111. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +462 -0
  112. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +71 -0
  113. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +36 -0
  114. package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/index.d.ts +117 -0
  115. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +72 -0
  116. package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/index.d.ts +117 -0
  117. package/vendor/claude-code-acp/node_modules/@types/node/tty.d.ts +250 -0
  118. package/vendor/claude-code-acp/node_modules/@types/node/url.d.ts +519 -0
  119. package/vendor/claude-code-acp/node_modules/@types/node/util/types.d.ts +558 -0
  120. package/vendor/claude-code-acp/node_modules/@types/node/util.d.ts +1662 -0
  121. package/vendor/claude-code-acp/node_modules/@types/node/v8.d.ts +983 -0
  122. package/vendor/claude-code-acp/node_modules/@types/node/vm.d.ts +1208 -0
  123. package/vendor/claude-code-acp/node_modules/@types/node/wasi.d.ts +202 -0
  124. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/abortcontroller.d.ts +59 -0
  125. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/blob.d.ts +23 -0
  126. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/console.d.ts +9 -0
  127. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/crypto.d.ts +39 -0
  128. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
  129. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/encoding.d.ts +11 -0
  130. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/events.d.ts +106 -0
  131. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/fetch.d.ts +69 -0
  132. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/importmeta.d.ts +13 -0
  133. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/messaging.d.ts +23 -0
  134. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/navigator.d.ts +25 -0
  135. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/performance.d.ts +45 -0
  136. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/storage.d.ts +24 -0
  137. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/streams.d.ts +115 -0
  138. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/timers.d.ts +44 -0
  139. package/vendor/claude-code-acp/node_modules/@types/node/web-globals/url.d.ts +24 -0
  140. package/vendor/claude-code-acp/node_modules/@types/node/worker_threads.d.ts +717 -0
  141. package/vendor/claude-code-acp/node_modules/@types/node/zlib.d.ts +618 -0
  142. package/vendor/claude-code-acp/node_modules/undici-types/LICENSE +21 -0
  143. package/vendor/claude-code-acp/node_modules/undici-types/README.md +6 -0
  144. package/vendor/claude-code-acp/node_modules/undici-types/agent.d.ts +32 -0
  145. package/vendor/claude-code-acp/node_modules/undici-types/api.d.ts +43 -0
  146. package/vendor/claude-code-acp/node_modules/undici-types/balanced-pool.d.ts +29 -0
  147. package/vendor/claude-code-acp/node_modules/undici-types/cache-interceptor.d.ts +172 -0
  148. package/vendor/claude-code-acp/node_modules/undici-types/cache.d.ts +36 -0
  149. package/vendor/claude-code-acp/node_modules/undici-types/client-stats.d.ts +15 -0
  150. package/vendor/claude-code-acp/node_modules/undici-types/client.d.ts +108 -0
  151. package/vendor/claude-code-acp/node_modules/undici-types/connector.d.ts +34 -0
  152. package/vendor/claude-code-acp/node_modules/undici-types/content-type.d.ts +21 -0
  153. package/vendor/claude-code-acp/node_modules/undici-types/cookies.d.ts +30 -0
  154. package/vendor/claude-code-acp/node_modules/undici-types/diagnostics-channel.d.ts +74 -0
  155. package/vendor/claude-code-acp/node_modules/undici-types/dispatcher.d.ts +276 -0
  156. package/vendor/claude-code-acp/node_modules/undici-types/env-http-proxy-agent.d.ts +22 -0
  157. package/vendor/claude-code-acp/node_modules/undici-types/errors.d.ts +161 -0
  158. package/vendor/claude-code-acp/node_modules/undici-types/eventsource.d.ts +66 -0
  159. package/vendor/claude-code-acp/node_modules/undici-types/fetch.d.ts +211 -0
  160. package/vendor/claude-code-acp/node_modules/undici-types/formdata.d.ts +108 -0
  161. package/vendor/claude-code-acp/node_modules/undici-types/global-dispatcher.d.ts +9 -0
  162. package/vendor/claude-code-acp/node_modules/undici-types/global-origin.d.ts +7 -0
  163. package/vendor/claude-code-acp/node_modules/undici-types/h2c-client.d.ts +73 -0
  164. package/vendor/claude-code-acp/node_modules/undici-types/handlers.d.ts +15 -0
  165. package/vendor/claude-code-acp/node_modules/undici-types/header.d.ts +160 -0
  166. package/vendor/claude-code-acp/node_modules/undici-types/index.d.ts +80 -0
  167. package/vendor/claude-code-acp/node_modules/undici-types/interceptors.d.ts +39 -0
  168. package/vendor/claude-code-acp/node_modules/undici-types/mock-agent.d.ts +68 -0
  169. package/vendor/claude-code-acp/node_modules/undici-types/mock-call-history.d.ts +111 -0
  170. package/vendor/claude-code-acp/node_modules/undici-types/mock-client.d.ts +27 -0
  171. package/vendor/claude-code-acp/node_modules/undici-types/mock-errors.d.ts +12 -0
  172. package/vendor/claude-code-acp/node_modules/undici-types/mock-interceptor.d.ts +94 -0
  173. package/vendor/claude-code-acp/node_modules/undici-types/mock-pool.d.ts +27 -0
  174. package/vendor/claude-code-acp/node_modules/undici-types/package.json +55 -0
  175. package/vendor/claude-code-acp/node_modules/undici-types/patch.d.ts +29 -0
  176. package/vendor/claude-code-acp/node_modules/undici-types/pool-stats.d.ts +19 -0
  177. package/vendor/claude-code-acp/node_modules/undici-types/pool.d.ts +41 -0
  178. package/vendor/claude-code-acp/node_modules/undici-types/proxy-agent.d.ts +29 -0
  179. package/vendor/claude-code-acp/node_modules/undici-types/readable.d.ts +68 -0
  180. package/vendor/claude-code-acp/node_modules/undici-types/retry-agent.d.ts +8 -0
  181. package/vendor/claude-code-acp/node_modules/undici-types/retry-handler.d.ts +125 -0
  182. package/vendor/claude-code-acp/node_modules/undici-types/snapshot-agent.d.ts +109 -0
  183. package/vendor/claude-code-acp/node_modules/undici-types/util.d.ts +18 -0
  184. package/vendor/claude-code-acp/node_modules/undici-types/utility.d.ts +7 -0
  185. package/vendor/claude-code-acp/node_modules/undici-types/webidl.d.ts +341 -0
  186. package/vendor/claude-code-acp/node_modules/undici-types/websocket.d.ts +186 -0
  187. package/dist/web/assets/index-B0sSFjwT.css +0 -1
  188. package/dist/web/assets/index-Cdb0JMLq.js +0 -13
@@ -4,35 +4,37 @@ import { mkdir, readFile, writeFile, access, readdir, open, stat } from "node:fs
4
4
  import { watch } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { ClaudeCliSession } from "./claude-cli-session.js";
7
+ import { ClaudeAcpSession } from "./acp-session.js";
7
8
  import { ActivityMonitor, projectDirPath } from "./activity-monitor.js";
8
9
  export class SessionManager {
10
+ static INITIAL_TIMELINE_WINDOW = 120;
11
+ static HISTORY_PAGE_SIZE = 120;
12
+ static OUTPUT_BUFFER_MAX = 256 * 1024;
13
+ static DISCOVERY_POLL_MS = 1000;
14
+ static DISCOVERY_MAX_POLLS = 60;
15
+ static HISTORY_DEBOUNCE_MS = 200;
16
+ static HISTORY_POLL_MS = 3000;
9
17
  allowedRoots;
10
18
  claudeBin;
19
+ agentBinPath;
11
20
  allowSkipPermissions;
12
21
  stateFilePath;
13
22
  sessions = new Map();
14
23
  listeners = new Set();
15
24
  activityMonitor;
16
- /** Reverse index: Claude sessionId → clientSessionId */
17
25
  sessionIdIndex = new Map();
18
- /** Timers for discovering new session IDs after /clear */
26
+ askUserQuestionMap = new Map();
19
27
  discoveryTimers = new Map();
20
- persistTimer = null;
21
- /** Maximum bytes of PTY output to keep per session for replay on reconnect. */
22
- static OUTPUT_BUFFER_MAX = 256 * 1024;
23
- static DISCOVERY_POLL_MS = 1000;
24
- static DISCOVERY_MAX_POLLS = 60; // ~1 minute
25
- // history.jsonl monitoring for /clear detection
26
28
  historyFilePath;
27
29
  historyWatcher = null;
28
30
  historyPollTimer = null;
29
31
  historyDebounceTimer = null;
30
32
  lastHistorySize = 0;
31
- static HISTORY_DEBOUNCE_MS = 200;
32
- static HISTORY_POLL_MS = 3000;
33
+ persistTimer = null;
33
34
  constructor(options) {
34
35
  this.allowedRoots = options.allowedRoots;
35
36
  this.claudeBin = options.claudeBin;
37
+ this.agentBinPath = options.agentBinPath;
36
38
  this.allowSkipPermissions = options.allowSkipPermissions ?? false;
37
39
  this.stateFilePath = path.join(os.homedir(), ".leduo-patrol", "state.json");
38
40
  this.historyFilePath = path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "history.jsonl");
@@ -44,12 +46,13 @@ export class SessionManager {
44
46
  if (!entry)
45
47
  return;
46
48
  entry.snapshot.activityState = activityState;
49
+ entry.snapshot.updatedAt = new Date().toISOString();
50
+ this.schedulePersist();
47
51
  this.emit({
48
52
  type: "session_activity",
49
53
  payload: { clientSessionId, activityState },
50
54
  });
51
55
  });
52
- // Set up plan session detection callback
53
56
  this.activityMonitor.onPlanSessionDetected = (newSessionId, promptId, workspacePath) => {
54
57
  this.handlePlanSessionDetected(newSessionId, promptId, workspacePath);
55
58
  };
@@ -60,7 +63,6 @@ export class SessionManager {
60
63
  for (const persisted of persistedState.sessions) {
61
64
  if (!(await this.isRestorableWorkspace(persisted.workspacePath))) {
62
65
  skippedPersistedSessions = true;
63
- console.warn(`[SessionManager] Skipping persisted session with unavailable workspace: ${persisted.workspacePath}`);
64
66
  continue;
65
67
  }
66
68
  const snapshot = {
@@ -70,26 +72,37 @@ export class SessionManager {
70
72
  connectionState: "connecting",
71
73
  activityState: "idle",
72
74
  sessionId: persisted.sessionId,
75
+ engine: persisted.engine === "acp" ? "acp" : "cli",
76
+ switchable: true,
73
77
  updatedAt: persisted.updatedAt,
74
78
  allowSkipPermissions: persisted.allowSkipPermissions,
79
+ acp: persisted.engine === "acp"
80
+ ? createEmptyAcpState(persisted.acpDefaultModeId, persisted.acpCurrentModeId)
81
+ : undefined,
75
82
  };
76
- this.sessions.set(snapshot.clientSessionId, {
83
+ const entry = {
77
84
  snapshot,
78
85
  cliSession: null,
86
+ cliExitExpected: false,
87
+ acpSession: null,
88
+ acpFullTimeline: [],
79
89
  outputBuffer: "",
80
- });
81
- this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
82
- this.activityMonitor.watch(snapshot.sessionId, snapshot.workspacePath);
90
+ switchInProgress: false,
91
+ };
92
+ this.sessions.set(snapshot.clientSessionId, entry);
93
+ if (snapshot.sessionId) {
94
+ this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
95
+ this.activityMonitor.watch(snapshot.sessionId, snapshot.workspacePath);
96
+ }
83
97
  }
84
98
  if (skippedPersistedSessions) {
85
99
  await this.writePersistedState().catch(() => undefined);
86
100
  }
87
101
  for (const entry of this.sessions.values()) {
88
- this.startCliSession(entry, true).catch((error) => {
102
+ this.startEngine(entry, Boolean(entry.snapshot.sessionId)).catch((error) => {
89
103
  this.handleManagerError(entry.snapshot.clientSessionId, error);
90
104
  });
91
105
  }
92
- // Start monitoring ~/.claude/history.jsonl for /clear commands
93
106
  await this.startHistoryMonitor();
94
107
  }
95
108
  subscribe(listener) {
@@ -101,29 +114,46 @@ export class SessionManager {
101
114
  getStateSnapshot() {
102
115
  return {
103
116
  sessions: [...this.sessions.values()]
104
- .map((entry) => entry.snapshot)
117
+ .map((entry) => this.snapshotForEvent(entry))
105
118
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
106
119
  };
107
120
  }
121
+ getAvailableEngines() {
122
+ return this.agentBinPath ? ["cli", "acp"] : ["cli"];
123
+ }
124
+ getSessionHistory(clientSessionId, before, limit = SessionManager.HISTORY_PAGE_SIZE) {
125
+ const entry = this.getEntry(clientSessionId);
126
+ if (!entry.snapshot.acp) {
127
+ throw new Error("Session history is only available for ACP sessions.");
128
+ }
129
+ const fullTimeline = this.ensureFullTimeline(entry);
130
+ const normalizedLimit = Number.isFinite(limit) ? limit : SessionManager.HISTORY_PAGE_SIZE;
131
+ const normalizedBefore = Number.isFinite(before) ? before : fullTimeline.length;
132
+ const safeLimit = Math.max(1, Math.min(normalizedLimit, SessionManager.HISTORY_PAGE_SIZE));
133
+ const safeBefore = Math.max(0, Math.min(normalizedBefore, fullTimeline.length));
134
+ const start = Math.max(0, safeBefore - safeLimit);
135
+ return {
136
+ items: fullTimeline.slice(start, safeBefore),
137
+ start,
138
+ total: fullTimeline.length,
139
+ };
140
+ }
108
141
  getSessionWorkspacePath(clientSessionId) {
109
142
  return this.getEntry(clientSessionId).snapshot.workspacePath;
110
143
  }
111
- async createSession(requestedWorkspacePath, requestedTitle, allowSkipPermissions) {
144
+ async createSession(requestedWorkspacePath, requestedTitle, allowSkipPermissions, engine = "cli") {
145
+ if (engine === "acp" && !this.agentBinPath) {
146
+ throw new Error("ACP engine is unavailable. Check LEDUO_PATROL_AGENT_BIN or bundled claude-code-acp.");
147
+ }
112
148
  const resolvedWorkspacePath = await this.resolveRequestedWorkspace(requestedWorkspacePath);
113
149
  const existingEntry = [...this.sessions.values()].find((entry) => entry.snapshot.workspacePath === resolvedWorkspacePath);
114
150
  if (existingEntry) {
115
151
  this.emit({
116
152
  type: "session_registered",
117
- payload: {
118
- clientSessionId: existingEntry.snapshot.clientSessionId,
119
- title: existingEntry.snapshot.title,
120
- workspacePath: existingEntry.snapshot.workspacePath,
121
- sessionId: existingEntry.snapshot.sessionId,
122
- },
153
+ payload: this.snapshotForEvent(existingEntry),
123
154
  });
124
155
  return existingEntry.snapshot;
125
156
  }
126
- const sessionId = randomUUID();
127
157
  const effectiveAllowSkipPermissions = allowSkipPermissions ?? this.allowSkipPermissions;
128
158
  const snapshot = {
129
159
  clientSessionId: randomUUID(),
@@ -131,51 +161,204 @@ export class SessionManager {
131
161
  workspacePath: resolvedWorkspacePath,
132
162
  connectionState: "connecting",
133
163
  activityState: "idle",
134
- sessionId,
164
+ sessionId: engine === "cli" ? randomUUID() : "",
165
+ engine,
166
+ switchable: true,
135
167
  updatedAt: new Date().toISOString(),
136
168
  allowSkipPermissions: effectiveAllowSkipPermissions,
169
+ acp: engine === "acp" ? createEmptyAcpState() : undefined,
137
170
  };
138
171
  const entry = {
139
172
  snapshot,
140
173
  cliSession: null,
174
+ cliExitExpected: false,
175
+ acpSession: null,
176
+ acpFullTimeline: [],
141
177
  outputBuffer: "",
178
+ switchInProgress: false,
142
179
  };
143
180
  this.sessions.set(snapshot.clientSessionId, entry);
144
- this.sessionIdIndex.set(sessionId, snapshot.clientSessionId);
145
- this.activityMonitor.watch(sessionId, resolvedWorkspacePath);
181
+ if (snapshot.sessionId) {
182
+ this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
183
+ this.activityMonitor.watch(snapshot.sessionId, resolvedWorkspacePath);
184
+ }
146
185
  this.schedulePersist();
147
186
  this.emit({
148
187
  type: "session_registered",
149
- payload: {
150
- clientSessionId: snapshot.clientSessionId,
151
- title: snapshot.title,
152
- workspacePath: snapshot.workspacePath,
153
- sessionId: snapshot.sessionId,
154
- },
188
+ payload: this.snapshotForEvent(entry),
189
+ });
190
+ try {
191
+ await this.startEngine(entry, false);
192
+ }
193
+ catch (error) {
194
+ entry.snapshot.updatedAt = new Date().toISOString();
195
+ this.schedulePersist();
196
+ this.emit({
197
+ type: "session_updated",
198
+ payload: this.snapshotForEvent(entry),
199
+ });
200
+ throw error;
201
+ }
202
+ this.emit({
203
+ type: "session_updated",
204
+ payload: this.snapshotForEvent(entry),
155
205
  });
156
- await this.startCliSession(entry, false);
157
206
  return snapshot;
158
207
  }
208
+ async switchEngine(clientSessionId, engine) {
209
+ const entry = this.getEntry(clientSessionId);
210
+ if (entry.snapshot.engine === engine) {
211
+ return entry.snapshot;
212
+ }
213
+ if (engine === "acp" && !this.agentBinPath) {
214
+ throw new Error("ACP engine is unavailable. Check LEDUO_PATROL_AGENT_BIN or bundled claude-code-acp.");
215
+ }
216
+ const blockedReason = this.getSwitchBlockedReason(entry);
217
+ if (blockedReason) {
218
+ throw new Error(`Session is not switchable: ${blockedReason}`);
219
+ }
220
+ const previousEngine = entry.snapshot.engine;
221
+ entry.switchInProgress = true;
222
+ try {
223
+ await this.stopEngine(entry, "switch");
224
+ entry.snapshot.engine = engine;
225
+ if (engine === "acp" && !entry.snapshot.acp) {
226
+ entry.snapshot.acp = createEmptyAcpState();
227
+ }
228
+ entry.snapshot.connectionState = "connecting";
229
+ entry.snapshot.updatedAt = new Date().toISOString();
230
+ this.schedulePersist();
231
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
232
+ entry.switchInProgress = false;
233
+ this.emit({
234
+ type: "session_updated",
235
+ payload: this.snapshotForEvent(entry),
236
+ });
237
+ return entry.snapshot;
238
+ }
239
+ catch (error) {
240
+ entry.snapshot.engine = previousEngine;
241
+ entry.snapshot.connectionState = "connecting";
242
+ try {
243
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
244
+ }
245
+ catch (rollbackError) {
246
+ entry.switchInProgress = false;
247
+ this.handleManagerError(clientSessionId, rollbackError);
248
+ throw error;
249
+ }
250
+ entry.switchInProgress = false;
251
+ this.emit({
252
+ type: "session_updated",
253
+ payload: this.snapshotForEvent(entry),
254
+ });
255
+ throw error;
256
+ }
257
+ }
159
258
  writeToSession(clientSessionId, data) {
160
259
  const entry = this.getEntry(clientSessionId);
260
+ if (entry.snapshot.engine !== "cli") {
261
+ throw new Error("CLI input is only available for CLI sessions.");
262
+ }
161
263
  entry.cliSession?.write(data);
162
264
  }
163
265
  resizeCliSession(clientSessionId, cols, rows) {
164
266
  const entry = this.getEntry(clientSessionId);
267
+ if (entry.snapshot.engine !== "cli") {
268
+ return;
269
+ }
165
270
  entry.cliSession?.resize(cols, rows);
166
271
  }
167
- /** Return buffered PTY output so a reconnecting client can replay history. */
168
272
  getSessionOutputBuffer(clientSessionId) {
169
273
  const entry = this.getEntry(clientSessionId);
170
274
  return entry.outputBuffer;
171
275
  }
276
+ async prompt(clientSessionId, text, modeId, images) {
277
+ const entry = this.getEntry(clientSessionId);
278
+ if (entry.snapshot.engine !== "acp") {
279
+ throw new Error("Prompting is only available in ACP mode.");
280
+ }
281
+ if (!entry.acpSession) {
282
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
283
+ }
284
+ const acpState = this.ensureAcpState(entry);
285
+ const effectiveModeId = modeId || acpState.defaultModeId;
286
+ if (effectiveModeId) {
287
+ await entry.acpSession?.setMode(effectiveModeId);
288
+ acpState.currentModeId = effectiveModeId;
289
+ }
290
+ entry.snapshot.updatedAt = new Date().toISOString();
291
+ this.schedulePersist();
292
+ await entry.acpSession?.prompt(text, images);
293
+ }
294
+ async setSessionMode(clientSessionId, modeId) {
295
+ const entry = this.getEntry(clientSessionId);
296
+ if (entry.snapshot.engine !== "acp") {
297
+ throw new Error("Session modes are only available in ACP mode.");
298
+ }
299
+ if (!modeId) {
300
+ return;
301
+ }
302
+ if (!entry.acpSession) {
303
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
304
+ }
305
+ const acpState = this.ensureAcpState(entry);
306
+ await entry.acpSession?.setMode(modeId);
307
+ acpState.defaultModeId = modeId;
308
+ acpState.currentModeId = modeId;
309
+ entry.snapshot.updatedAt = new Date().toISOString();
310
+ this.schedulePersist();
311
+ this.emit({
312
+ type: "session_mode_changed",
313
+ payload: {
314
+ clientSessionId,
315
+ defaultModeId: acpState.defaultModeId,
316
+ currentModeId: acpState.currentModeId,
317
+ },
318
+ });
319
+ }
320
+ async cancel(clientSessionId) {
321
+ const entry = this.getEntry(clientSessionId);
322
+ if (entry.snapshot.engine !== "acp") {
323
+ return;
324
+ }
325
+ await entry.acpSession?.cancel();
326
+ }
327
+ async resolvePermission(clientSessionId, requestId, optionId, note) {
328
+ const entry = this.getEntry(clientSessionId);
329
+ if (entry.snapshot.engine !== "acp") {
330
+ throw new Error("Permissions are only available in ACP mode.");
331
+ }
332
+ await entry.acpSession?.resolvePermission(requestId, optionId, note);
333
+ }
334
+ async answerQuestion(clientSessionId, questionId, answer) {
335
+ const entry = this.getEntry(clientSessionId);
336
+ if (entry.snapshot.engine !== "acp") {
337
+ throw new Error("Questions are only available in ACP mode.");
338
+ }
339
+ const mappedPermission = this.askUserQuestionMap.get(questionId);
340
+ if (mappedPermission) {
341
+ const siblingIds = [];
342
+ for (const [qId, mapping] of this.askUserQuestionMap.entries()) {
343
+ if (mapping.requestId === mappedPermission.requestId) {
344
+ siblingIds.push(qId);
345
+ }
346
+ }
347
+ for (const qId of siblingIds) {
348
+ this.askUserQuestionMap.delete(qId);
349
+ }
350
+ await entry.acpSession?.resolvePermission(mappedPermission.requestId, "deny", answer);
351
+ return;
352
+ }
353
+ await entry.acpSession?.answerQuestion(questionId, answer);
354
+ }
172
355
  async closeSession(clientSessionId) {
173
356
  const entry = this.sessions.get(clientSessionId);
174
357
  if (!entry) {
175
358
  return;
176
359
  }
177
360
  this.clearDiscoveryTimer(clientSessionId);
178
- entry.cliSession?.kill();
361
+ await this.stopEngine(entry, "close");
179
362
  this.activityMonitor.unwatch(entry.snapshot.sessionId);
180
363
  this.sessionIdIndex.delete(entry.snapshot.sessionId);
181
364
  this.sessions.delete(clientSessionId);
@@ -185,8 +368,36 @@ export class SessionManager {
185
368
  payload: { clientSessionId },
186
369
  });
187
370
  }
371
+ async startEngine(entry, resume) {
372
+ entry.snapshot.connectionState = "connecting";
373
+ entry.snapshot.updatedAt = new Date().toISOString();
374
+ this.schedulePersist();
375
+ if (entry.snapshot.engine === "cli") {
376
+ await this.startCliSession(entry, resume);
377
+ return;
378
+ }
379
+ await this.startAcpSession(entry, resume);
380
+ }
381
+ async stopEngine(entry, reason) {
382
+ if (reason === "switch") {
383
+ entry.outputBuffer = "";
384
+ }
385
+ if (entry.cliSession) {
386
+ entry.cliExitExpected = true;
387
+ entry.cliSession.kill();
388
+ entry.cliSession = null;
389
+ }
390
+ if (entry.acpSession) {
391
+ await entry.acpSession.dispose();
392
+ entry.acpSession = null;
393
+ }
394
+ }
188
395
  async startCliSession(entry, resume) {
189
396
  const { snapshot } = entry;
397
+ if (!snapshot.sessionId) {
398
+ snapshot.sessionId = randomUUID();
399
+ this.bindSessionId(snapshot.clientSessionId, "", snapshot.sessionId, snapshot.workspacePath, false);
400
+ }
190
401
  try {
191
402
  const cliSession = new ClaudeCliSession({
192
403
  workspacePath: snapshot.workspacePath,
@@ -196,8 +407,8 @@ export class SessionManager {
196
407
  allowSkipPermissions: snapshot.allowSkipPermissions,
197
408
  });
198
409
  entry.cliSession = cliSession;
410
+ entry.cliExitExpected = false;
199
411
  cliSession.on("output", (data) => {
200
- // Append to ring buffer so reconnecting clients can replay history
201
412
  entry.outputBuffer += data;
202
413
  if (entry.outputBuffer.length > SessionManager.OUTPUT_BUFFER_MAX) {
203
414
  entry.outputBuffer = entry.outputBuffer.slice(-SessionManager.OUTPUT_BUFFER_MAX);
@@ -208,6 +419,12 @@ export class SessionManager {
208
419
  });
209
420
  });
210
421
  cliSession.on("exit", (exitCode) => {
422
+ const expected = entry.cliExitExpected;
423
+ entry.cliExitExpected = false;
424
+ entry.cliSession = null;
425
+ if (expected) {
426
+ return;
427
+ }
211
428
  this.clearDiscoveryTimer(snapshot.clientSessionId);
212
429
  snapshot.connectionState = "error";
213
430
  snapshot.updatedAt = new Date().toISOString();
@@ -226,11 +443,559 @@ export class SessionManager {
226
443
  throw error;
227
444
  }
228
445
  }
446
+ async startAcpSession(entry, resume) {
447
+ if (!this.agentBinPath) {
448
+ throw new Error("ACP engine is unavailable.");
449
+ }
450
+ const acpSession = new ClaudeAcpSession({
451
+ workspacePath: entry.snapshot.workspacePath,
452
+ agentBinPath: this.agentBinPath,
453
+ claudeBin: this.claudeBin,
454
+ onEvent: (event) => this.handleAcpSessionEvent(entry.snapshot.clientSessionId, event),
455
+ });
456
+ entry.acpSession = acpSession;
457
+ await acpSession.connect();
458
+ if (resume && entry.snapshot.sessionId) {
459
+ entry.acpFullTimeline = [];
460
+ this.syncVisibleTimeline(entry);
461
+ const restorableSessionId = await acpSession.findRestorableSession(entry.snapshot.sessionId);
462
+ if (restorableSessionId) {
463
+ if (restorableSessionId !== entry.snapshot.sessionId) {
464
+ this.bindSessionId(entry.snapshot.clientSessionId, entry.snapshot.sessionId, restorableSessionId, entry.snapshot.workspacePath, true);
465
+ }
466
+ await acpSession.loadSession(restorableSessionId);
467
+ }
468
+ else {
469
+ await acpSession.ensureSession();
470
+ }
471
+ }
472
+ else {
473
+ await acpSession.ensureSession();
474
+ }
475
+ const acpState = this.ensureAcpState(entry);
476
+ if (acpState.defaultModeId && acpState.currentModeId !== acpState.defaultModeId) {
477
+ await acpSession.setMode(acpState.defaultModeId);
478
+ acpState.currentModeId = acpState.defaultModeId;
479
+ }
480
+ entry.snapshot.connectionState = "connected";
481
+ entry.snapshot.updatedAt = new Date().toISOString();
482
+ this.schedulePersist();
483
+ }
484
+ handleAcpSessionEvent(clientSessionId, event) {
485
+ const entry = this.sessions.get(clientSessionId);
486
+ if (!entry) {
487
+ return;
488
+ }
489
+ const acpState = this.ensureAcpState(entry);
490
+ let shouldEmitFullSnapshot = false;
491
+ switch (event.type) {
492
+ case "ready":
493
+ entry.snapshot.connectionState = "connected";
494
+ entry.snapshot.workspacePath = event.payload.workspacePath;
495
+ this.appendTimeline(entry, {
496
+ id: randomUUID(),
497
+ kind: "system",
498
+ title: "Claude ACP 已连接",
499
+ body: event.payload.workspacePath,
500
+ });
501
+ shouldEmitFullSnapshot = true;
502
+ break;
503
+ case "session_created":
504
+ this.bindSessionId(clientSessionId, entry.snapshot.sessionId, event.payload.sessionId, entry.snapshot.workspacePath, false);
505
+ entry.snapshot.connectionState = "connected";
506
+ acpState.modes = event.payload.modes;
507
+ acpState.currentModeId = acpState.currentModeId || acpState.defaultModeId || "default";
508
+ this.appendTimeline(entry, {
509
+ id: randomUUID(),
510
+ kind: "system",
511
+ title: "会话已创建",
512
+ body: event.payload.sessionId,
513
+ meta: labelForMode(acpState.currentModeId || acpState.defaultModeId),
514
+ });
515
+ shouldEmitFullSnapshot = true;
516
+ break;
517
+ case "session_restored":
518
+ this.bindSessionId(clientSessionId, entry.snapshot.sessionId, event.payload.sessionId, entry.snapshot.workspacePath, false);
519
+ entry.snapshot.connectionState = "connected";
520
+ acpState.modes = event.payload.modes;
521
+ this.appendTimeline(entry, {
522
+ id: randomUUID(),
523
+ kind: "system",
524
+ title: "会话已恢复",
525
+ body: event.payload.sessionId,
526
+ meta: labelForMode(acpState.currentModeId || acpState.defaultModeId),
527
+ });
528
+ shouldEmitFullSnapshot = true;
529
+ break;
530
+ case "prompt_started":
531
+ acpState.busy = true;
532
+ this.appendTimeline(entry, {
533
+ id: event.payload.promptId,
534
+ kind: "user",
535
+ title: "你",
536
+ body: event.payload.text,
537
+ });
538
+ break;
539
+ case "prompt_finished":
540
+ acpState.busy = false;
541
+ this.appendTimeline(entry, {
542
+ id: randomUUID(),
543
+ kind: "system",
544
+ title: "本轮完成",
545
+ body: event.payload.stopReason,
546
+ });
547
+ break;
548
+ case "session_update":
549
+ this.consumeSessionUpdate(entry, event.payload);
550
+ break;
551
+ case "permission_requested": {
552
+ const normalizedTitle = normalizeAcpToolTitle(event.payload.toolCall.title) || undefined;
553
+ if (isAskUserQuestionTitle(normalizedTitle)) {
554
+ const rawInput = asRecord(event.payload.toolCall.rawInput);
555
+ const rawQuestions = Array.isArray(rawInput?.questions) ? rawInput.questions : [];
556
+ const parsedQuestions = [];
557
+ if (rawQuestions.length > 0) {
558
+ for (const rawQ of rawQuestions) {
559
+ const q = asRecord(rawQ);
560
+ if (!q)
561
+ continue;
562
+ const questionStr = typeof q.question === "string" ? q.question : "";
563
+ const headerStr = typeof q.header === "string" ? q.header : undefined;
564
+ const rawOpts = Array.isArray(q.options) ? q.options : [];
565
+ const options = rawOpts
566
+ .map((opt) => {
567
+ const o = asRecord(opt);
568
+ if (!o)
569
+ return null;
570
+ const label = typeof o.label === "string" ? o.label : "";
571
+ const description = typeof o.description === "string" ? o.description : undefined;
572
+ return label ? { id: label, label, description } : null;
573
+ })
574
+ .filter((o) => o !== null);
575
+ parsedQuestions.push({
576
+ question: questionStr,
577
+ header: headerStr,
578
+ options,
579
+ allowCustomAnswer: true,
580
+ });
581
+ }
582
+ }
583
+ else {
584
+ const questionText = typeof rawInput?.question === "string" ? rawInput.question : "";
585
+ parsedQuestions.push({
586
+ question: questionText,
587
+ header: undefined,
588
+ options: [],
589
+ allowCustomAnswer: true,
590
+ });
591
+ }
592
+ const groupId = randomUUID();
593
+ const questionIds = [];
594
+ for (const pq of parsedQuestions) {
595
+ const questionId = randomUUID();
596
+ questionIds.push(questionId);
597
+ const questionSnapshot = {
598
+ clientSessionId,
599
+ questionId,
600
+ groupId,
601
+ question: pq.question,
602
+ header: pq.header,
603
+ options: pq.options,
604
+ allowCustomAnswer: true,
605
+ };
606
+ acpState.questions.push(questionSnapshot);
607
+ this.appendTimeline(entry, {
608
+ id: questionId,
609
+ kind: "system",
610
+ title: "提问",
611
+ body: pq.header ? `【${pq.header}】${pq.question}` : pq.question,
612
+ meta: "pending",
613
+ });
614
+ this.askUserQuestionMap.set(questionId, {
615
+ clientSessionId,
616
+ requestId: event.payload.requestId,
617
+ });
618
+ }
619
+ entry.snapshot.updatedAt = new Date().toISOString();
620
+ this.schedulePersist();
621
+ for (let i = 0; i < parsedQuestions.length; i += 1) {
622
+ const pq = parsedQuestions[i];
623
+ this.emit({
624
+ type: "question_requested",
625
+ payload: {
626
+ clientSessionId,
627
+ questionId: questionIds[i],
628
+ groupId,
629
+ question: pq.question,
630
+ header: pq.header,
631
+ options: pq.options,
632
+ allowCustomAnswer: true,
633
+ },
634
+ });
635
+ }
636
+ return;
637
+ }
638
+ const permission = {
639
+ clientSessionId,
640
+ requestId: event.payload.requestId,
641
+ toolCall: {
642
+ toolCallId: event.payload.toolCall.toolCallId,
643
+ title: normalizedTitle,
644
+ status: event.payload.toolCall.status ?? undefined,
645
+ rawInput: event.payload.toolCall.rawInput,
646
+ },
647
+ options: event.payload.options.map((option) => ({
648
+ optionId: option.optionId,
649
+ name: option.name,
650
+ kind: option.kind,
651
+ })),
652
+ };
653
+ acpState.permissions.push(permission);
654
+ this.appendTimeline(entry, {
655
+ id: event.payload.requestId,
656
+ kind: "tool",
657
+ title: summarizeToolTitle(normalizedTitle, event.payload.toolCall.rawInput, event.payload.toolCall.toolCallId),
658
+ body: formatToolDetails({
659
+ toolCallId: event.payload.toolCall.toolCallId,
660
+ title: normalizedTitle,
661
+ status: event.payload.toolCall.status,
662
+ rawInput: event.payload.toolCall.rawInput,
663
+ }),
664
+ meta: event.payload.toolCall.status ?? "pending",
665
+ });
666
+ break;
667
+ }
668
+ case "permission_resolved":
669
+ acpState.permissions = acpState.permissions.filter((permission) => permission.requestId !== event.payload.requestId);
670
+ entry.snapshot.acp = { ...acpState };
671
+ break;
672
+ case "question_requested": {
673
+ const questionSnapshot = {
674
+ clientSessionId,
675
+ questionId: event.payload.questionId,
676
+ question: event.payload.question,
677
+ options: event.payload.options.map((opt) => ({
678
+ id: opt.id,
679
+ label: opt.label,
680
+ })),
681
+ allowCustomAnswer: event.payload.allowCustomAnswer,
682
+ };
683
+ acpState.questions.push(questionSnapshot);
684
+ this.appendTimeline(entry, {
685
+ id: event.payload.questionId,
686
+ kind: "system",
687
+ title: "提问",
688
+ body: event.payload.question,
689
+ meta: "pending",
690
+ });
691
+ break;
692
+ }
693
+ case "question_answered":
694
+ acpState.questions = acpState.questions.filter((question) => question.questionId !== event.payload.questionId);
695
+ entry.snapshot.acp = { ...acpState };
696
+ break;
697
+ case "error": {
698
+ const editChangeMessage = formatEditToolChangeMessage(event.payload.message);
699
+ if (editChangeMessage) {
700
+ this.appendTimeline(entry, {
701
+ id: randomUUID(),
702
+ kind: "tool",
703
+ title: editChangeMessage.title,
704
+ body: editChangeMessage.body,
705
+ meta: "completed",
706
+ });
707
+ break;
708
+ }
709
+ if (event.payload.fatal) {
710
+ acpState.busy = false;
711
+ entry.snapshot.connectionState = "error";
712
+ shouldEmitFullSnapshot = true;
713
+ }
714
+ this.appendTimeline(entry, {
715
+ id: randomUUID(),
716
+ kind: "error",
717
+ title: event.payload.fatal ? "错误" : "警告",
718
+ body: event.payload.message,
719
+ });
720
+ break;
721
+ }
722
+ }
723
+ entry.snapshot.updatedAt = new Date().toISOString();
724
+ this.schedulePersist();
725
+ if (shouldEmitFullSnapshot) {
726
+ this.emitSessionUpdated(entry);
727
+ }
728
+ switch (event.type) {
729
+ case "prompt_started":
730
+ this.emit({
731
+ type: "prompt_started",
732
+ payload: {
733
+ clientSessionId,
734
+ promptId: event.payload.promptId,
735
+ text: event.payload.text,
736
+ },
737
+ });
738
+ break;
739
+ case "prompt_finished":
740
+ this.emit({
741
+ type: "prompt_finished",
742
+ payload: {
743
+ clientSessionId,
744
+ promptId: event.payload.promptId,
745
+ stopReason: event.payload.stopReason,
746
+ },
747
+ });
748
+ break;
749
+ case "session_update":
750
+ this.emit({
751
+ type: "session_update",
752
+ payload: {
753
+ clientSessionId,
754
+ ...event.payload,
755
+ sessionUpdate: event.payload.sessionUpdate,
756
+ },
757
+ });
758
+ break;
759
+ case "permission_requested":
760
+ this.emit({
761
+ type: "permission_requested",
762
+ payload: {
763
+ clientSessionId,
764
+ requestId: event.payload.requestId,
765
+ toolCall: {
766
+ toolCallId: event.payload.toolCall.toolCallId,
767
+ title: event.payload.toolCall.title ?? undefined,
768
+ status: event.payload.toolCall.status ?? undefined,
769
+ rawInput: event.payload.toolCall.rawInput,
770
+ },
771
+ options: event.payload.options.map((option) => ({
772
+ optionId: option.optionId,
773
+ name: option.name,
774
+ kind: option.kind,
775
+ })),
776
+ },
777
+ });
778
+ break;
779
+ case "permission_resolved":
780
+ this.emit({
781
+ type: "permission_resolved",
782
+ payload: {
783
+ clientSessionId,
784
+ requestId: event.payload.requestId,
785
+ optionId: event.payload.optionId,
786
+ },
787
+ });
788
+ break;
789
+ case "question_requested":
790
+ this.emit({
791
+ type: "question_requested",
792
+ payload: {
793
+ clientSessionId,
794
+ questionId: event.payload.questionId,
795
+ question: event.payload.question,
796
+ options: event.payload.options.map((option) => ({
797
+ id: option.id,
798
+ label: option.label,
799
+ })),
800
+ allowCustomAnswer: event.payload.allowCustomAnswer,
801
+ },
802
+ });
803
+ break;
804
+ case "question_answered":
805
+ this.emit({
806
+ type: "question_answered",
807
+ payload: {
808
+ clientSessionId,
809
+ questionId: event.payload.questionId,
810
+ answer: event.payload.answer,
811
+ },
812
+ });
813
+ break;
814
+ case "error":
815
+ this.emit({
816
+ type: "error",
817
+ payload: {
818
+ clientSessionId,
819
+ message: event.payload.message,
820
+ fatal: event.payload.fatal,
821
+ },
822
+ });
823
+ break;
824
+ default:
825
+ break;
826
+ }
827
+ }
828
+ consumeSessionUpdate(entry, update) {
829
+ const acpState = this.ensureAcpState(entry);
830
+ switch (update.sessionUpdate) {
831
+ case "available_commands_update":
832
+ acpState.availableCommands = normalizeAvailableCommandsSnapshot(update.availableCommands ?? update.supportedCommands ?? update.commands);
833
+ entry.snapshot.acp = { ...acpState };
834
+ break;
835
+ case "agent_message_chunk": {
836
+ const chunkText = extractChunkText(update.content);
837
+ if (chunkText) {
838
+ const parentId = extractParentToolCallId(update);
839
+ this.appendTextChunk(entry, "agent", "Claude", chunkText, parentId);
840
+ }
841
+ break;
842
+ }
843
+ case "agent_thought_chunk": {
844
+ const chunkText = extractChunkText(update.content);
845
+ if (chunkText) {
846
+ const parentId = extractParentToolCallId(update);
847
+ this.appendTextChunk(entry, "thought", "思路", chunkText, parentId);
848
+ }
849
+ break;
850
+ }
851
+ case "tool_call":
852
+ case "tool_call_update": {
853
+ const claudeCodeMeta = asRecord(asRecord(update._meta)?.claudeCode);
854
+ const metaToolName = typeof claudeCodeMeta?.toolName === "string" ? claudeCodeMeta.toolName : undefined;
855
+ const normalizedTitle = normalizeAcpToolTitle(update.title) || normalizeAcpToolTitle(metaToolName) || undefined;
856
+ const parentToolCallId = extractParentToolCallId(update);
857
+ const effectiveStatus = isAskUserQuestionTitle(normalizedTitle) && update.status === "failed"
858
+ ? "completed"
859
+ : update.status;
860
+ this.appendTimeline(entry, {
861
+ id: randomUUID(),
862
+ kind: "tool",
863
+ title: summarizeToolTitle(normalizedTitle, update.rawInput, update.toolCallId),
864
+ body: formatToolDetails({
865
+ toolCallId: update.toolCallId,
866
+ title: normalizedTitle,
867
+ status: effectiveStatus,
868
+ rawInput: update.rawInput,
869
+ rawOutput: update.rawOutput,
870
+ parentToolCallId,
871
+ }),
872
+ meta: String(effectiveStatus ?? update.sessionUpdate),
873
+ });
874
+ break;
875
+ }
876
+ case "plan": {
877
+ const parentToolCallId = extractParentToolCallId(update);
878
+ this.appendTimeline(entry, {
879
+ id: randomUUID(),
880
+ kind: "system",
881
+ title: "执行计划",
882
+ body: stringifyMaybe(update.entries ?? update),
883
+ parentToolCallId,
884
+ });
885
+ break;
886
+ }
887
+ case "current_mode_update":
888
+ acpState.currentModeId = String(update.currentModeId ?? acpState.currentModeId ?? "default");
889
+ this.appendTimeline(entry, {
890
+ id: randomUUID(),
891
+ kind: "system",
892
+ title: "模式切换",
893
+ body: String(update.currentModeId ?? "unknown"),
894
+ });
895
+ break;
896
+ default:
897
+ break;
898
+ }
899
+ }
900
+ ensureAcpState(entry) {
901
+ if (!entry.snapshot.acp) {
902
+ entry.snapshot.acp = createEmptyAcpState();
903
+ }
904
+ return entry.snapshot.acp;
905
+ }
906
+ appendTextChunk(entry, kind, title, text, parentToolCallId) {
907
+ const fullTimeline = this.ensureFullTimeline(entry);
908
+ const lastItem = fullTimeline.at(-1);
909
+ if (lastItem &&
910
+ lastItem.kind === kind &&
911
+ lastItem.title === title &&
912
+ !lastItem.meta &&
913
+ (lastItem.parentToolCallId ?? undefined) === parentToolCallId) {
914
+ lastItem.body += text;
915
+ this.syncVisibleTimeline(entry);
916
+ return;
917
+ }
918
+ this.appendTimeline(entry, {
919
+ id: randomUUID(),
920
+ kind,
921
+ title,
922
+ body: text,
923
+ parentToolCallId,
924
+ });
925
+ }
926
+ appendTimeline(entry, item) {
927
+ this.ensureFullTimeline(entry).push(item);
928
+ this.syncVisibleTimeline(entry);
929
+ }
930
+ syncVisibleTimeline(entry) {
931
+ const acpState = this.ensureAcpState(entry);
932
+ const fullTimeline = this.ensureFullTimeline(entry);
933
+ const total = fullTimeline.length;
934
+ const start = Math.max(0, total - SessionManager.INITIAL_TIMELINE_WINDOW);
935
+ acpState.timeline = fullTimeline.slice(start);
936
+ acpState.historyTotal = total;
937
+ acpState.historyStart = start;
938
+ }
939
+ ensureFullTimeline(entry) {
940
+ if (!Array.isArray(entry.acpFullTimeline)) {
941
+ entry.acpFullTimeline = [];
942
+ }
943
+ return entry.acpFullTimeline;
944
+ }
229
945
  emit(event) {
230
946
  for (const listener of this.listeners) {
231
947
  listener(event);
232
948
  }
233
949
  }
950
+ emitSessionUpdated(entry) {
951
+ this.emit({
952
+ type: "session_updated",
953
+ payload: this.snapshotForEvent(entry),
954
+ });
955
+ }
956
+ snapshotForEvent(entry) {
957
+ const blockedReason = this.getSwitchBlockedReason(entry);
958
+ const snapshot = structuredClone(entry.snapshot);
959
+ snapshot.switchable = !blockedReason;
960
+ snapshot.switchBlockedReason = blockedReason ?? undefined;
961
+ return snapshot;
962
+ }
963
+ getSwitchBlockedReason(entry) {
964
+ if (entry.switchInProgress) {
965
+ return "切换中";
966
+ }
967
+ if (entry.snapshot.connectionState === "connecting") {
968
+ return "连接中";
969
+ }
970
+ if (entry.snapshot.engine === "cli") {
971
+ if (entry.snapshot.activityState === "running")
972
+ return "运行中";
973
+ if (entry.snapshot.activityState === "pending")
974
+ return "待处理";
975
+ return null;
976
+ }
977
+ const acpState = entry.snapshot.acp;
978
+ if (!acpState) {
979
+ return null;
980
+ }
981
+ if (acpState.permissions.length > 0)
982
+ return "待审批";
983
+ if (acpState.questions.length > 0)
984
+ return "待提问";
985
+ if (this.isAcpBusy(acpState))
986
+ return "运行中";
987
+ return null;
988
+ }
989
+ isAcpBusy(acpState) {
990
+ if (!acpState.busy) {
991
+ return false;
992
+ }
993
+ const latestItem = acpState.timeline.at(-1);
994
+ if (latestItem?.kind === "system" && latestItem.title === "本轮完成") {
995
+ return false;
996
+ }
997
+ return true;
998
+ }
234
999
  schedulePersist() {
235
1000
  if (this.persistTimer) {
236
1001
  clearTimeout(this.persistTimer);
@@ -246,8 +1011,11 @@ export class SessionManager {
246
1011
  title: session.title,
247
1012
  workspacePath: session.workspacePath,
248
1013
  sessionId: session.sessionId,
1014
+ engine: session.engine,
249
1015
  updatedAt: session.updatedAt,
250
1016
  allowSkipPermissions: session.allowSkipPermissions,
1017
+ acpDefaultModeId: session.acp?.defaultModeId,
1018
+ acpCurrentModeId: session.acp?.currentModeId,
251
1019
  }));
252
1020
  await mkdir(path.dirname(this.stateFilePath), { recursive: true });
253
1021
  await writeFile(this.stateFilePath, JSON.stringify({ sessions: persistedSessions }, null, 2), "utf8");
@@ -297,43 +1065,59 @@ export class SessionManager {
297
1065
  return false;
298
1066
  }
299
1067
  }
300
- // ---------------------------------------------------------------------------
301
- // /clear detection session ID discovery
302
- // ---------------------------------------------------------------------------
1068
+ bindSessionId(clientSessionId, oldSessionId, newSessionId, workspacePath, emitChangeEvent) {
1069
+ if (!newSessionId || oldSessionId === newSessionId) {
1070
+ if (newSessionId) {
1071
+ this.sessionIdIndex.set(newSessionId, clientSessionId);
1072
+ }
1073
+ return;
1074
+ }
1075
+ if (oldSessionId) {
1076
+ this.sessionIdIndex.delete(oldSessionId);
1077
+ this.activityMonitor.switchWatch(oldSessionId, newSessionId, workspacePath);
1078
+ }
1079
+ else {
1080
+ this.activityMonitor.watch(newSessionId, workspacePath);
1081
+ }
1082
+ this.sessionIdIndex.set(newSessionId, clientSessionId);
1083
+ const entry = this.sessions.get(clientSessionId);
1084
+ if (entry) {
1085
+ entry.snapshot.sessionId = newSessionId;
1086
+ entry.snapshot.updatedAt = new Date().toISOString();
1087
+ this.schedulePersist();
1088
+ }
1089
+ if (emitChangeEvent) {
1090
+ this.emit({
1091
+ type: "session_id_updated",
1092
+ payload: { clientSessionId, newSessionId },
1093
+ });
1094
+ }
1095
+ }
303
1096
  handlePlanSessionDetected(newSessionId, promptId, workspacePath) {
304
1097
  const oldSessionId = this.activityMonitor.getSessionIdByPromptId(promptId);
305
1098
  if (!oldSessionId) {
306
- console.log(`[SessionManager] handlePlanSessionDetected: no oldSessionId found for promptId ${promptId}`);
307
1099
  return;
308
1100
  }
309
1101
  const clientSessionId = this.sessionIdIndex.get(oldSessionId);
310
1102
  if (!clientSessionId) {
311
- console.log(`[SessionManager] handlePlanSessionDetected: no clientSessionId found for oldSessionId ${oldSessionId}`);
312
1103
  return;
313
1104
  }
314
- console.log(`[SessionManager] Plan session detected: ${oldSessionId} → ${newSessionId} (promptId=${promptId})`);
315
1105
  this.completeSessionSwitch(clientSessionId, oldSessionId, newSessionId, workspacePath);
316
1106
  }
317
1107
  handleSessionClear(oldSessionId, workspacePath) {
318
1108
  const clientSessionId = this.sessionIdIndex.get(oldSessionId);
319
1109
  if (!clientSessionId) {
320
- console.log(`[SessionManager] handleSessionClear: no clientSessionId found for ${oldSessionId}`);
321
1110
  return;
322
1111
  }
323
1112
  const entry = this.sessions.get(clientSessionId);
324
1113
  if (!entry) {
325
- console.log(`[SessionManager] handleSessionClear: no entry found for clientSessionId ${clientSessionId}`);
326
1114
  return;
327
1115
  }
328
- console.log(`[SessionManager] handleSessionClear: oldSessionId=${oldSessionId}, clientSessionId=${clientSessionId}, workspace=${workspacePath}`);
329
- // Immediately mark as idle — the old session is done
330
1116
  entry.snapshot.activityState = "idle";
331
1117
  this.emit({
332
1118
  type: "session_activity",
333
1119
  payload: { clientSessionId, activityState: "idle" },
334
1120
  });
335
- // Delay 500ms before starting discovery — the new session file is created
336
- // almost simultaneously with /clear in history.jsonl.
337
1121
  const delayTimer = setTimeout(() => {
338
1122
  this.startNewSessionDiscovery(clientSessionId, oldSessionId, workspacePath);
339
1123
  }, 500);
@@ -342,44 +1126,40 @@ export class SessionManager {
342
1126
  startNewSessionDiscovery(clientSessionId, oldSessionId, workspacePath) {
343
1127
  const dirPath = projectDirPath(workspacePath);
344
1128
  let pollCount = 0;
345
- console.log(`[SessionManager] startNewSessionDiscovery: dir=${dirPath}, oldSession=${oldSessionId}`);
346
1129
  const tryFind = async () => {
347
1130
  try {
348
- const files = (await readdir(dirPath)).filter((f) => f.endsWith(".jsonl"));
349
- // Stat each file (except old session) to find the most recently modified ones
1131
+ const files = (await readdir(dirPath)).filter((file) => file.endsWith(".jsonl"));
350
1132
  const candidates = [];
351
- for (const f of files) {
352
- const sid = f.replace(/\.jsonl$/, "");
1133
+ for (const file of files) {
1134
+ const sid = file.replace(/\.jsonl$/, "");
353
1135
  if (sid === oldSessionId)
354
1136
  continue;
355
1137
  try {
356
- const s = await stat(path.join(dirPath, f));
357
- candidates.push({ name: f, mtimeMs: s.mtimeMs });
1138
+ const fileStat = await stat(path.join(dirPath, file));
1139
+ candidates.push({ name: file, mtimeMs: fileStat.mtimeMs });
358
1140
  }
359
1141
  catch {
360
- // skip unreadable
1142
+ // ignore unreadable candidate
361
1143
  }
362
1144
  }
363
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
364
- // Check the top 3 newest files for /clear content
1145
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
365
1146
  for (const candidate of candidates.slice(0, 3)) {
366
1147
  const filePath = path.join(dirPath, candidate.name);
367
1148
  let fd;
368
1149
  try {
369
1150
  fd = await open(filePath, "r");
370
- const buf = Buffer.alloc(4096);
371
- const { bytesRead } = await fd.read(buf, 0, 4096, 0);
372
- const head = buf.toString("utf8", 0, bytesRead);
1151
+ const buffer = Buffer.alloc(4096);
1152
+ const { bytesRead } = await fd.read(buffer, 0, 4096, 0);
1153
+ const head = buffer.toString("utf8", 0, bytesRead);
373
1154
  if (head.includes("<command-name>/clear</command-name>")) {
374
1155
  const newSessionId = candidate.name.replace(/\.jsonl$/, "");
375
- console.log(`[SessionManager] discovery confirmed new session: ${candidate.name} (contains /clear)`);
376
1156
  this.clearDiscoveryTimer(clientSessionId);
377
1157
  this.completeSessionSwitch(clientSessionId, oldSessionId, newSessionId, workspacePath);
378
1158
  return true;
379
1159
  }
380
1160
  }
381
1161
  catch {
382
- // skip
1162
+ // ignore
383
1163
  }
384
1164
  finally {
385
1165
  await fd?.close();
@@ -387,18 +1167,16 @@ export class SessionManager {
387
1167
  }
388
1168
  }
389
1169
  catch {
390
- // Directory may not exist yet
1170
+ // ignore
391
1171
  }
392
1172
  return false;
393
1173
  };
394
- // Immediate first attempt, then retry every 1s up to ~1 minute
395
1174
  tryFind().then((found) => {
396
1175
  if (found)
397
1176
  return;
398
1177
  const timer = setInterval(async () => {
399
- pollCount++;
1178
+ pollCount += 1;
400
1179
  if (pollCount > SessionManager.DISCOVERY_MAX_POLLS) {
401
- console.log(`[SessionManager] discovery timed out for ${oldSessionId}`);
402
1180
  this.clearDiscoveryTimer(clientSessionId);
403
1181
  return;
404
1182
  }
@@ -411,22 +1189,9 @@ export class SessionManager {
411
1189
  const entry = this.sessions.get(clientSessionId);
412
1190
  if (!entry)
413
1191
  return;
414
- console.log(`[SessionManager] completeSessionSwitch: ${oldSessionId} → ${newSessionId} (client=${clientSessionId})`);
415
- // Update reverse index
416
- this.sessionIdIndex.delete(oldSessionId);
417
- this.sessionIdIndex.set(newSessionId, clientSessionId);
418
- // Update snapshot
419
- entry.snapshot.sessionId = newSessionId;
1192
+ this.bindSessionId(clientSessionId, oldSessionId, newSessionId, workspacePath, true);
420
1193
  entry.snapshot.updatedAt = new Date().toISOString();
421
- // Switch activity monitor to watch the new JSONL file
422
- this.activityMonitor.switchWatch(oldSessionId, newSessionId, workspacePath);
423
- // Persist so --resume uses the new session ID
424
1194
  this.schedulePersist();
425
- // Notify frontend
426
- this.emit({
427
- type: "session_id_updated",
428
- payload: { clientSessionId, newSessionId },
429
- });
430
1195
  }
431
1196
  clearDiscoveryTimer(clientSessionId) {
432
1197
  const timer = this.discoveryTimers.get(clientSessionId);
@@ -435,25 +1200,19 @@ export class SessionManager {
435
1200
  this.discoveryTimers.delete(clientSessionId);
436
1201
  }
437
1202
  }
438
- // ---------------------------------------------------------------------------
439
- // history.jsonl monitoring — detect /clear commands globally
440
- // ---------------------------------------------------------------------------
441
1203
  async startHistoryMonitor() {
442
- // Get initial file size so we only process new lines
443
1204
  try {
444
- const stats = await stat(this.historyFilePath);
445
- this.lastHistorySize = stats.size;
1205
+ const historyStats = await stat(this.historyFilePath);
1206
+ this.lastHistorySize = historyStats.size;
446
1207
  }
447
1208
  catch {
448
1209
  this.lastHistorySize = 0;
449
1210
  }
450
- console.log(`[SessionManager] Watching ${this.historyFilePath} for /clear commands (initial size=${this.lastHistorySize})`);
451
1211
  try {
452
1212
  this.historyWatcher = watch(this.historyFilePath, () => {
453
1213
  this.scheduleHistoryCheck();
454
1214
  });
455
1215
  this.historyWatcher.on("error", () => {
456
- // Fall back to polling if the watcher fails
457
1216
  this.historyWatcher?.close();
458
1217
  this.historyWatcher = null;
459
1218
  if (!this.historyPollTimer) {
@@ -464,7 +1223,6 @@ export class SessionManager {
464
1223
  });
465
1224
  }
466
1225
  catch {
467
- // Watcher unavailable — use polling
468
1226
  this.historyPollTimer = setInterval(() => {
469
1227
  this.checkHistoryUpdates().catch(() => undefined);
470
1228
  }, SessionManager.HISTORY_POLL_MS);
@@ -481,68 +1239,54 @@ export class SessionManager {
481
1239
  async checkHistoryUpdates() {
482
1240
  let fd;
483
1241
  try {
484
- const stats = await stat(this.historyFilePath);
485
- // File was truncated or rotated — reset
486
- if (stats.size < this.lastHistorySize) {
487
- this.lastHistorySize = stats.size;
1242
+ const historyStats = await stat(this.historyFilePath);
1243
+ if (historyStats.size < this.lastHistorySize) {
1244
+ this.lastHistorySize = historyStats.size;
488
1245
  return;
489
1246
  }
490
- // No new content
491
- if (stats.size === this.lastHistorySize)
1247
+ if (historyStats.size === this.lastHistorySize)
492
1248
  return;
493
- const readSize = stats.size - this.lastHistorySize;
1249
+ const readSize = historyStats.size - this.lastHistorySize;
494
1250
  fd = await open(this.historyFilePath, "r");
495
1251
  const buffer = Buffer.alloc(readSize);
496
1252
  await fd.read(buffer, 0, readSize, this.lastHistorySize);
497
- this.lastHistorySize = stats.size;
1253
+ this.lastHistorySize = historyStats.size;
498
1254
  const newText = buffer.toString("utf8");
499
- const lines = newText.split("\n").filter((l) => l.trim().length > 0);
1255
+ const lines = newText.split("\n").filter((line) => line.trim().length > 0);
500
1256
  for (const line of lines) {
501
1257
  try {
502
1258
  const entry = JSON.parse(line);
503
1259
  if (entry.display === "/clear" && entry.sessionId) {
504
- // Check if we're tracking this session
505
1260
  const clientSessionId = this.sessionIdIndex.get(entry.sessionId);
506
1261
  if (clientSessionId) {
507
1262
  const managed = this.sessions.get(clientSessionId);
508
1263
  if (managed) {
509
- console.log(`[SessionManager] /clear detected in history.jsonl for session ${entry.sessionId} (client=${clientSessionId})`);
510
1264
  this.handleSessionClear(entry.sessionId, managed.snapshot.workspacePath);
511
1265
  }
512
1266
  }
513
1267
  }
514
1268
  }
515
1269
  catch {
516
- // Skip malformed lines
1270
+ // ignore malformed line
517
1271
  }
518
1272
  }
519
1273
  }
520
1274
  catch {
521
- // File doesn't exist or unreadable — will retry on next trigger
1275
+ // ignore
522
1276
  }
523
1277
  finally {
524
1278
  await fd?.close();
525
1279
  }
526
1280
  }
527
- stopHistoryMonitor() {
528
- if (this.historyWatcher) {
529
- this.historyWatcher.close();
530
- this.historyWatcher = null;
531
- }
532
- if (this.historyPollTimer) {
533
- clearInterval(this.historyPollTimer);
534
- this.historyPollTimer = null;
535
- }
536
- if (this.historyDebounceTimer) {
537
- clearTimeout(this.historyDebounceTimer);
538
- this.historyDebounceTimer = null;
539
- }
540
- }
541
1281
  handleManagerError(clientSessionId, error) {
542
1282
  const entry = this.sessions.get(clientSessionId);
543
1283
  if (!entry) {
544
1284
  return;
545
1285
  }
1286
+ const acpState = entry.snapshot.acp;
1287
+ if (acpState) {
1288
+ acpState.busy = false;
1289
+ }
546
1290
  entry.snapshot.connectionState = "error";
547
1291
  entry.snapshot.updatedAt = new Date().toISOString();
548
1292
  this.schedulePersist();
@@ -556,6 +1300,169 @@ export class SessionManager {
556
1300
  });
557
1301
  }
558
1302
  }
1303
+ function createEmptyAcpState(defaultModeId = "default", currentModeId = defaultModeId) {
1304
+ return {
1305
+ modes: [],
1306
+ defaultModeId,
1307
+ currentModeId,
1308
+ busy: false,
1309
+ timeline: [],
1310
+ historyTotal: 0,
1311
+ historyStart: 0,
1312
+ permissions: [],
1313
+ questions: [],
1314
+ availableCommands: [],
1315
+ };
1316
+ }
1317
+ function stringifyMaybe(value) {
1318
+ if (typeof value === "string") {
1319
+ return value;
1320
+ }
1321
+ return JSON.stringify(value, null, 2);
1322
+ }
1323
+ function formatToolDetails(details) {
1324
+ return stringifyMaybe({
1325
+ toolCallId: details.toolCallId,
1326
+ title: details.title,
1327
+ status: details.status,
1328
+ rawInput: details.rawInput,
1329
+ rawOutput: details.rawOutput,
1330
+ ...(details.parentToolCallId ? { parentToolCallId: details.parentToolCallId } : undefined),
1331
+ });
1332
+ }
1333
+ function normalizeAcpToolTitle(rawTitle) {
1334
+ if (typeof rawTitle !== "string")
1335
+ return "";
1336
+ return rawTitle.replace(/^mcp__acp__/i, "");
1337
+ }
1338
+ function summarizeToolTitle(rawTitle, rawInput, rawToolCallId) {
1339
+ const title = normalizeAcpToolTitle(rawTitle).trim();
1340
+ const record = asRecord(rawInput) ?? asRecord(tryParseJson(rawInput));
1341
+ const normalizedTitle = title.toLowerCase();
1342
+ const isSubagent = normalizedTitle.includes("subagent") || normalizedTitle === "task" || normalizedTitle.includes(" task");
1343
+ if (isSubagent) {
1344
+ const summary = readSubagentSummary(record);
1345
+ if (summary) {
1346
+ return `${title || "Task"} · ${summary}`;
1347
+ }
1348
+ }
1349
+ if (title && !/^工具\s+tool_/.test(title) && !/^tool_/.test(title)) {
1350
+ return title;
1351
+ }
1352
+ const command = typeof record?.command === "string"
1353
+ ? record.command
1354
+ : Array.isArray(record?.cmd)
1355
+ ? record.cmd.filter((part) => typeof part === "string").join(" ")
1356
+ : null;
1357
+ const pathValue = typeof record?.path === "string"
1358
+ ? record.path
1359
+ : typeof record?.filePath === "string"
1360
+ ? record.filePath
1361
+ : typeof record?.cwd === "string"
1362
+ ? record.cwd
1363
+ : null;
1364
+ const description = typeof record?.description === "string" ? record.description : null;
1365
+ const args = Array.isArray(record?.args) ? record.args.filter((part) => typeof part === "string").join(" ") : null;
1366
+ const summary = [command, pathValue, description, args].filter(Boolean).join(" · ");
1367
+ if (summary) {
1368
+ return summary;
1369
+ }
1370
+ if (title) {
1371
+ return title;
1372
+ }
1373
+ return typeof rawToolCallId === "string" ? `工具 ${rawToolCallId}` : "工具调用";
1374
+ }
1375
+ function readSubagentSummary(record) {
1376
+ if (!record) {
1377
+ return null;
1378
+ }
1379
+ for (const key of ["title", "description"]) {
1380
+ const value = record[key];
1381
+ if (typeof value === "string" && value.trim()) {
1382
+ return value.trim();
1383
+ }
1384
+ }
1385
+ for (const key of ["rawInput", "input", "args", "payload", "params"]) {
1386
+ if (!(key in record)) {
1387
+ continue;
1388
+ }
1389
+ const nestedRecord = asRecord(record[key]) ?? asRecord(tryParseJson(record[key]));
1390
+ const nested = readSubagentSummary(nestedRecord);
1391
+ if (nested) {
1392
+ return nested;
1393
+ }
1394
+ }
1395
+ return null;
1396
+ }
1397
+ function tryParseJson(value) {
1398
+ if (typeof value !== "string") {
1399
+ return null;
1400
+ }
1401
+ const trimmed = value.trim();
1402
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
1403
+ return null;
1404
+ }
1405
+ try {
1406
+ return JSON.parse(trimmed);
1407
+ }
1408
+ catch {
1409
+ return null;
1410
+ }
1411
+ }
1412
+ function asRecord(value) {
1413
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1414
+ }
1415
+ function extractParentToolCallId(update) {
1416
+ const meta = asRecord(update._meta);
1417
+ const claudeCode = asRecord(meta?.claudeCode);
1418
+ const parentId = claudeCode?.parentToolUseId;
1419
+ return typeof parentId === "string" && parentId ? parentId : undefined;
1420
+ }
1421
+ function formatEditToolChangeMessage(message) {
1422
+ const parsed = tryParseJson(message);
1423
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1424
+ return null;
1425
+ }
1426
+ const files = parsed
1427
+ .map((entry) => asRecord(entry))
1428
+ .filter((entry) => Boolean(entry))
1429
+ .map((entry) => {
1430
+ const filePath = typeof entry.newFileName === "string"
1431
+ ? entry.newFileName
1432
+ : typeof entry.oldFileName === "string"
1433
+ ? entry.oldFileName
1434
+ : typeof entry.index === "string"
1435
+ ? entry.index
1436
+ : "";
1437
+ const hunks = Array.isArray(entry.hunks) ? entry.hunks.length : 0;
1438
+ return { filePath, hunks };
1439
+ })
1440
+ .filter((entry) => entry.filePath);
1441
+ if (files.length === 0) {
1442
+ return null;
1443
+ }
1444
+ const lines = files.map((entry) => `- ${entry.filePath}${entry.hunks > 0 ? `(${entry.hunks} 处修改)` : ""}`);
1445
+ return {
1446
+ title: `Edit 已修改 ${files.length} 个文件`,
1447
+ body: `Edit 工具已更新以下文件:\n${lines.join("\n")}`,
1448
+ };
1449
+ }
1450
+ function labelForMode(modeId) {
1451
+ switch (modeId) {
1452
+ case "default":
1453
+ return "Default";
1454
+ case "acceptEdits":
1455
+ return "Accept Edits";
1456
+ case "plan":
1457
+ return "Plan";
1458
+ case "dontAsk":
1459
+ return "Don't Ask";
1460
+ case "bypassPermissions":
1461
+ return "Bypass Permissions";
1462
+ default:
1463
+ return modeId || "默认模式";
1464
+ }
1465
+ }
559
1466
  function formatError(error) {
560
1467
  if (error instanceof Error) {
561
1468
  return error.message;
@@ -567,6 +1474,95 @@ function formatError(error) {
567
1474
  return String(error);
568
1475
  }
569
1476
  }
1477
+ function extractChunkText(content) {
1478
+ if (!content) {
1479
+ return null;
1480
+ }
1481
+ if (Array.isArray(content)) {
1482
+ const joined = content.map((item) => extractChunkText(item)).filter((item) => Boolean(item)).join("\n");
1483
+ return joined || null;
1484
+ }
1485
+ const record = asRecord(content);
1486
+ if (!record) {
1487
+ return null;
1488
+ }
1489
+ if (typeof record.text === "string" && record.text.trim()) {
1490
+ return record.text;
1491
+ }
1492
+ if (record.type === "resource") {
1493
+ const resource = asRecord(record.resource);
1494
+ if (typeof resource?.text === "string" && resource.text.trim()) {
1495
+ return resource.text;
1496
+ }
1497
+ }
1498
+ if (record.type === "resource_link") {
1499
+ const uri = typeof record.uri === "string" ? record.uri : "";
1500
+ return uri ? `[resource] ${uri}` : "[resource]";
1501
+ }
1502
+ return null;
1503
+ }
1504
+ function normalizeAvailableCommandsSnapshot(rawValue) {
1505
+ if (!Array.isArray(rawValue)) {
1506
+ return [];
1507
+ }
1508
+ const seen = new Set();
1509
+ const normalized = [];
1510
+ for (const item of rawValue) {
1511
+ if (typeof item === "string") {
1512
+ const name = normalizeCommandName(item);
1513
+ if (!name || seen.has(name)) {
1514
+ continue;
1515
+ }
1516
+ seen.add(name);
1517
+ normalized.push({ name, description: "", inputType: "unstructured" });
1518
+ continue;
1519
+ }
1520
+ const record = asRecord(item);
1521
+ const rawName = typeof record?.name === "string"
1522
+ ? record.name
1523
+ : typeof record?.command === "string"
1524
+ ? record.command
1525
+ : "";
1526
+ const name = normalizeCommandName(rawName);
1527
+ if (!name || seen.has(name)) {
1528
+ continue;
1529
+ }
1530
+ seen.add(name);
1531
+ normalized.push({
1532
+ name,
1533
+ description: typeof record?.description === "string"
1534
+ ? record.description.trim()
1535
+ : typeof record?.title === "string"
1536
+ ? record.title.trim()
1537
+ : "",
1538
+ inputType: "unstructured",
1539
+ });
1540
+ }
1541
+ return normalized.sort((left, right) => left.name.localeCompare(right.name, "zh-Hans-CN"));
1542
+ }
1543
+ function normalizeCommandName(rawName) {
1544
+ const trimmed = rawName.trim();
1545
+ if (!trimmed) {
1546
+ return "";
1547
+ }
1548
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
1549
+ }
1550
+ function isAskUserQuestionTitle(title) {
1551
+ if (!title)
1552
+ return false;
1553
+ const lower = title.toLowerCase();
1554
+ return lower === "askuserquestion" || lower.startsWith("askuserquestion ");
1555
+ }
570
1556
  export const sessionManagerTestables = {
1557
+ stringifyMaybe,
1558
+ formatToolDetails,
1559
+ summarizeToolTitle,
1560
+ normalizeAcpToolTitle,
1561
+ isAskUserQuestionTitle,
1562
+ asRecord,
1563
+ extractChunkText,
1564
+ normalizeAvailableCommandsSnapshot,
1565
+ formatEditToolChangeMessage,
1566
+ labelForMode,
571
1567
  formatError,
572
1568
  };