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
@@ -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,38 @@ 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, normalizeQueuedPrompts(persisted.acpQueuedPrompts))
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
+ acpQueueDrainActive: false,
92
+ };
93
+ this.sessions.set(snapshot.clientSessionId, entry);
94
+ if (snapshot.sessionId) {
95
+ this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
96
+ this.activityMonitor.watch(snapshot.sessionId, snapshot.workspacePath);
97
+ }
83
98
  }
84
99
  if (skippedPersistedSessions) {
85
100
  await this.writePersistedState().catch(() => undefined);
86
101
  }
87
102
  for (const entry of this.sessions.values()) {
88
- this.startCliSession(entry, true).catch((error) => {
103
+ this.startEngine(entry, Boolean(entry.snapshot.sessionId)).catch((error) => {
89
104
  this.handleManagerError(entry.snapshot.clientSessionId, error);
90
105
  });
91
106
  }
92
- // Start monitoring ~/.claude/history.jsonl for /clear commands
93
107
  await this.startHistoryMonitor();
94
108
  }
95
109
  subscribe(listener) {
@@ -101,29 +115,46 @@ export class SessionManager {
101
115
  getStateSnapshot() {
102
116
  return {
103
117
  sessions: [...this.sessions.values()]
104
- .map((entry) => entry.snapshot)
118
+ .map((entry) => this.snapshotForEvent(entry))
105
119
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
106
120
  };
107
121
  }
122
+ getAvailableEngines() {
123
+ return this.agentBinPath ? ["cli", "acp"] : ["cli"];
124
+ }
125
+ getSessionHistory(clientSessionId, before, limit = SessionManager.HISTORY_PAGE_SIZE) {
126
+ const entry = this.getEntry(clientSessionId);
127
+ if (!entry.snapshot.acp) {
128
+ throw new Error("Session history is only available for ACP sessions.");
129
+ }
130
+ const fullTimeline = this.ensureFullTimeline(entry);
131
+ const normalizedLimit = Number.isFinite(limit) ? limit : SessionManager.HISTORY_PAGE_SIZE;
132
+ const normalizedBefore = Number.isFinite(before) ? before : fullTimeline.length;
133
+ const safeLimit = Math.max(1, Math.min(normalizedLimit, SessionManager.HISTORY_PAGE_SIZE));
134
+ const safeBefore = Math.max(0, Math.min(normalizedBefore, fullTimeline.length));
135
+ const start = Math.max(0, safeBefore - safeLimit);
136
+ return {
137
+ items: fullTimeline.slice(start, safeBefore),
138
+ start,
139
+ total: fullTimeline.length,
140
+ };
141
+ }
108
142
  getSessionWorkspacePath(clientSessionId) {
109
143
  return this.getEntry(clientSessionId).snapshot.workspacePath;
110
144
  }
111
- async createSession(requestedWorkspacePath, requestedTitle, allowSkipPermissions) {
145
+ async createSession(requestedWorkspacePath, requestedTitle, allowSkipPermissions, engine = "cli") {
146
+ if (engine === "acp" && !this.agentBinPath) {
147
+ throw new Error("ACP engine is unavailable. Check LEDUO_PATROL_AGENT_BIN or bundled claude-code-acp.");
148
+ }
112
149
  const resolvedWorkspacePath = await this.resolveRequestedWorkspace(requestedWorkspacePath);
113
150
  const existingEntry = [...this.sessions.values()].find((entry) => entry.snapshot.workspacePath === resolvedWorkspacePath);
114
151
  if (existingEntry) {
115
152
  this.emit({
116
153
  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
- },
154
+ payload: this.snapshotForEvent(existingEntry),
123
155
  });
124
156
  return existingEntry.snapshot;
125
157
  }
126
- const sessionId = randomUUID();
127
158
  const effectiveAllowSkipPermissions = allowSkipPermissions ?? this.allowSkipPermissions;
128
159
  const snapshot = {
129
160
  clientSessionId: randomUUID(),
@@ -131,51 +162,230 @@ export class SessionManager {
131
162
  workspacePath: resolvedWorkspacePath,
132
163
  connectionState: "connecting",
133
164
  activityState: "idle",
134
- sessionId,
165
+ sessionId: engine === "cli" ? randomUUID() : "",
166
+ engine,
167
+ switchable: true,
135
168
  updatedAt: new Date().toISOString(),
136
169
  allowSkipPermissions: effectiveAllowSkipPermissions,
170
+ acp: engine === "acp" ? createEmptyAcpState() : undefined,
137
171
  };
138
172
  const entry = {
139
173
  snapshot,
140
174
  cliSession: null,
175
+ cliExitExpected: false,
176
+ acpSession: null,
177
+ acpFullTimeline: [],
141
178
  outputBuffer: "",
179
+ switchInProgress: false,
180
+ acpQueueDrainActive: false,
142
181
  };
143
182
  this.sessions.set(snapshot.clientSessionId, entry);
144
- this.sessionIdIndex.set(sessionId, snapshot.clientSessionId);
145
- this.activityMonitor.watch(sessionId, resolvedWorkspacePath);
183
+ if (snapshot.sessionId) {
184
+ this.sessionIdIndex.set(snapshot.sessionId, snapshot.clientSessionId);
185
+ this.activityMonitor.watch(snapshot.sessionId, resolvedWorkspacePath);
186
+ }
146
187
  this.schedulePersist();
147
188
  this.emit({
148
189
  type: "session_registered",
149
- payload: {
150
- clientSessionId: snapshot.clientSessionId,
151
- title: snapshot.title,
152
- workspacePath: snapshot.workspacePath,
153
- sessionId: snapshot.sessionId,
154
- },
190
+ payload: this.snapshotForEvent(entry),
191
+ });
192
+ try {
193
+ await this.startEngine(entry, false);
194
+ }
195
+ catch (error) {
196
+ entry.snapshot.updatedAt = new Date().toISOString();
197
+ this.schedulePersist();
198
+ this.emit({
199
+ type: "session_updated",
200
+ payload: this.snapshotForEvent(entry),
201
+ });
202
+ throw error;
203
+ }
204
+ this.emit({
205
+ type: "session_updated",
206
+ payload: this.snapshotForEvent(entry),
155
207
  });
156
- await this.startCliSession(entry, false);
157
208
  return snapshot;
158
209
  }
210
+ async switchEngine(clientSessionId, engine) {
211
+ const entry = this.getEntry(clientSessionId);
212
+ if (entry.snapshot.engine === engine) {
213
+ return entry.snapshot;
214
+ }
215
+ if (engine === "acp" && !this.agentBinPath) {
216
+ throw new Error("ACP engine is unavailable. Check LEDUO_PATROL_AGENT_BIN or bundled claude-code-acp.");
217
+ }
218
+ const blockedReason = this.getSwitchBlockedReason(entry);
219
+ if (blockedReason) {
220
+ throw new Error(`Session is not switchable: ${blockedReason}`);
221
+ }
222
+ const previousEngine = entry.snapshot.engine;
223
+ entry.switchInProgress = true;
224
+ try {
225
+ await this.stopEngine(entry, "switch");
226
+ entry.snapshot.engine = engine;
227
+ if (engine === "acp" && !entry.snapshot.acp) {
228
+ entry.snapshot.acp = createEmptyAcpState();
229
+ }
230
+ entry.snapshot.connectionState = "connecting";
231
+ entry.snapshot.updatedAt = new Date().toISOString();
232
+ this.schedulePersist();
233
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
234
+ entry.switchInProgress = false;
235
+ this.emit({
236
+ type: "session_updated",
237
+ payload: this.snapshotForEvent(entry),
238
+ });
239
+ return entry.snapshot;
240
+ }
241
+ catch (error) {
242
+ entry.snapshot.engine = previousEngine;
243
+ entry.snapshot.connectionState = "connecting";
244
+ try {
245
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
246
+ }
247
+ catch (rollbackError) {
248
+ entry.switchInProgress = false;
249
+ this.handleManagerError(clientSessionId, rollbackError);
250
+ throw error;
251
+ }
252
+ entry.switchInProgress = false;
253
+ this.emit({
254
+ type: "session_updated",
255
+ payload: this.snapshotForEvent(entry),
256
+ });
257
+ throw error;
258
+ }
259
+ }
159
260
  writeToSession(clientSessionId, data) {
160
261
  const entry = this.getEntry(clientSessionId);
262
+ if (entry.snapshot.engine !== "cli") {
263
+ throw new Error("CLI input is only available for CLI sessions.");
264
+ }
161
265
  entry.cliSession?.write(data);
162
266
  }
163
267
  resizeCliSession(clientSessionId, cols, rows) {
164
268
  const entry = this.getEntry(clientSessionId);
269
+ if (entry.snapshot.engine !== "cli") {
270
+ return;
271
+ }
165
272
  entry.cliSession?.resize(cols, rows);
166
273
  }
167
- /** Return buffered PTY output so a reconnecting client can replay history. */
168
274
  getSessionOutputBuffer(clientSessionId) {
169
275
  const entry = this.getEntry(clientSessionId);
170
276
  return entry.outputBuffer;
171
277
  }
278
+ async prompt(clientSessionId, text, modeId, images) {
279
+ const entry = this.getEntry(clientSessionId);
280
+ if (entry.snapshot.engine !== "acp") {
281
+ throw new Error("Prompting is only available in ACP mode.");
282
+ }
283
+ if (!entry.acpSession) {
284
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
285
+ }
286
+ const acpState = this.ensureAcpState(entry);
287
+ const effectiveModeId = modeId || acpState.currentModeId || acpState.defaultModeId || "default";
288
+ const nextImages = images ?? [];
289
+ if (this.shouldQueueAcpPrompt(entry)) {
290
+ this.enqueueAcpPrompt(entry, text, nextImages, effectiveModeId);
291
+ void this.drainAcpPromptQueue(entry);
292
+ return;
293
+ }
294
+ await this.sendAcpPrompt(entry, {
295
+ id: randomUUID(),
296
+ text,
297
+ images: nextImages,
298
+ modeId: effectiveModeId,
299
+ createdAt: new Date().toISOString(),
300
+ status: "sending",
301
+ });
302
+ }
303
+ async setSessionMode(clientSessionId, modeId) {
304
+ const entry = this.getEntry(clientSessionId);
305
+ if (entry.snapshot.engine !== "acp") {
306
+ throw new Error("Session modes are only available in ACP mode.");
307
+ }
308
+ if (!modeId) {
309
+ return;
310
+ }
311
+ if (!entry.acpSession) {
312
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
313
+ }
314
+ const acpState = this.ensureAcpState(entry);
315
+ await entry.acpSession?.setMode(modeId);
316
+ acpState.defaultModeId = modeId;
317
+ acpState.currentModeId = modeId;
318
+ entry.snapshot.updatedAt = new Date().toISOString();
319
+ this.schedulePersist();
320
+ this.emit({
321
+ type: "session_mode_changed",
322
+ payload: {
323
+ clientSessionId,
324
+ defaultModeId: acpState.defaultModeId,
325
+ currentModeId: acpState.currentModeId,
326
+ },
327
+ });
328
+ }
329
+ async cancel(clientSessionId) {
330
+ const entry = this.getEntry(clientSessionId);
331
+ if (entry.snapshot.engine !== "acp") {
332
+ return;
333
+ }
334
+ await entry.acpSession?.cancel();
335
+ }
336
+ async resolvePermission(clientSessionId, requestId, optionId, note) {
337
+ const entry = this.getEntry(clientSessionId);
338
+ if (entry.snapshot.engine !== "acp") {
339
+ throw new Error("Permissions are only available in ACP mode.");
340
+ }
341
+ await entry.acpSession?.resolvePermission(requestId, optionId, note);
342
+ }
343
+ async answerQuestion(clientSessionId, questionId, answer) {
344
+ const entry = this.getEntry(clientSessionId);
345
+ if (entry.snapshot.engine !== "acp") {
346
+ throw new Error("Questions are only available in ACP mode.");
347
+ }
348
+ const mappedPermission = this.askUserQuestionMap.get(questionId);
349
+ if (mappedPermission) {
350
+ const siblingIds = [];
351
+ for (const [qId, mapping] of this.askUserQuestionMap.entries()) {
352
+ if (mapping.requestId === mappedPermission.requestId) {
353
+ siblingIds.push(qId);
354
+ }
355
+ }
356
+ for (const qId of siblingIds) {
357
+ this.askUserQuestionMap.delete(qId);
358
+ }
359
+ await entry.acpSession?.resolvePermission(mappedPermission.requestId, "deny", answer);
360
+ return;
361
+ }
362
+ await entry.acpSession?.answerQuestion(questionId, answer);
363
+ }
364
+ async removeQueuedPrompt(clientSessionId, promptId) {
365
+ const entry = this.getEntry(clientSessionId);
366
+ if (entry.snapshot.engine !== "acp") {
367
+ throw new Error("Queued prompts are only available in ACP mode.");
368
+ }
369
+ const acpState = this.ensureAcpState(entry);
370
+ const prompt = acpState.queuedPrompts.find((item) => item.id === promptId);
371
+ if (!prompt) {
372
+ return;
373
+ }
374
+ if (prompt.status === "sending") {
375
+ throw new Error("Queued prompt is currently sending and cannot be removed.");
376
+ }
377
+ acpState.queuedPrompts = acpState.queuedPrompts.filter((item) => item.id !== promptId);
378
+ entry.snapshot.updatedAt = new Date().toISOString();
379
+ this.schedulePersist();
380
+ this.emitSessionUpdated(entry);
381
+ }
172
382
  async closeSession(clientSessionId) {
173
383
  const entry = this.sessions.get(clientSessionId);
174
384
  if (!entry) {
175
385
  return;
176
386
  }
177
387
  this.clearDiscoveryTimer(clientSessionId);
178
- entry.cliSession?.kill();
388
+ await this.stopEngine(entry, "close");
179
389
  this.activityMonitor.unwatch(entry.snapshot.sessionId);
180
390
  this.sessionIdIndex.delete(entry.snapshot.sessionId);
181
391
  this.sessions.delete(clientSessionId);
@@ -185,8 +395,143 @@ export class SessionManager {
185
395
  payload: { clientSessionId },
186
396
  });
187
397
  }
398
+ shouldQueueAcpPrompt(entry) {
399
+ const acpState = this.ensureAcpState(entry);
400
+ return (entry.acpQueueDrainActive
401
+ || acpState.queuedPrompts.length > 0
402
+ || acpState.permissions.length > 0
403
+ || acpState.questions.length > 0
404
+ || this.isAcpBusy(acpState));
405
+ }
406
+ enqueueAcpPrompt(entry, text, images, modeId) {
407
+ const acpState = this.ensureAcpState(entry);
408
+ acpState.queuedPrompts.push({
409
+ id: randomUUID(),
410
+ text,
411
+ images,
412
+ modeId,
413
+ createdAt: new Date().toISOString(),
414
+ status: "queued",
415
+ });
416
+ entry.snapshot.updatedAt = new Date().toISOString();
417
+ this.schedulePersist();
418
+ this.emitSessionUpdated(entry);
419
+ }
420
+ async sendAcpPrompt(entry, prompt) {
421
+ if (!entry.acpSession) {
422
+ await this.startEngine(entry, Boolean(entry.snapshot.sessionId));
423
+ }
424
+ const acpState = this.ensureAcpState(entry);
425
+ if (prompt.modeId) {
426
+ await entry.acpSession?.setMode(prompt.modeId);
427
+ acpState.currentModeId = prompt.modeId;
428
+ }
429
+ entry.snapshot.updatedAt = new Date().toISOString();
430
+ this.schedulePersist();
431
+ await entry.acpSession?.prompt(prompt.text, prompt.images);
432
+ }
433
+ canDrainAcpPromptQueue(entry) {
434
+ if (entry.snapshot.engine !== "acp" || entry.snapshot.connectionState !== "connected") {
435
+ return false;
436
+ }
437
+ if (!entry.acpSession || entry.acpQueueDrainActive) {
438
+ return false;
439
+ }
440
+ const acpState = this.ensureAcpState(entry);
441
+ return (acpState.permissions.length === 0
442
+ && acpState.questions.length === 0
443
+ && !this.isAcpBusy(acpState)
444
+ && acpState.queuedPrompts.length > 0);
445
+ }
446
+ async drainAcpPromptQueue(entry) {
447
+ if (!this.canDrainAcpPromptQueue(entry)) {
448
+ return;
449
+ }
450
+ const acpState = this.ensureAcpState(entry);
451
+ const nextPrompt = acpState.queuedPrompts[0];
452
+ if (!nextPrompt) {
453
+ return;
454
+ }
455
+ const drainingPromptId = nextPrompt.id;
456
+ entry.acpQueueDrainActive = true;
457
+ try {
458
+ if (nextPrompt.status !== "sending") {
459
+ nextPrompt.status = "sending";
460
+ entry.snapshot.updatedAt = new Date().toISOString();
461
+ this.schedulePersist();
462
+ this.emitSessionUpdated(entry);
463
+ }
464
+ await this.sendAcpPrompt(entry, nextPrompt);
465
+ }
466
+ catch (error) {
467
+ if (!isAcpPromptBusyError(error)) {
468
+ this.handleManagerError(entry.snapshot.clientSessionId, error);
469
+ }
470
+ }
471
+ finally {
472
+ entry.acpQueueDrainActive = false;
473
+ if (this.canDrainAcpPromptQueue(entry) && this.ensureAcpState(entry).queuedPrompts[0]?.id !== drainingPromptId) {
474
+ void this.drainAcpPromptQueue(entry);
475
+ }
476
+ }
477
+ }
478
+ recoverSendingQueuedPrompts(entry) {
479
+ const acpState = this.ensureAcpState(entry);
480
+ if (acpState.queuedPrompts.length === 0) {
481
+ return false;
482
+ }
483
+ let changed = false;
484
+ const canResumeQueueHead = acpState.permissions.length === 0
485
+ && acpState.questions.length === 0
486
+ && !this.isAcpBusy(acpState);
487
+ acpState.queuedPrompts = acpState.queuedPrompts.map((prompt, index) => {
488
+ if (prompt.status !== "sending") {
489
+ return prompt;
490
+ }
491
+ if (index === 0 && canResumeQueueHead) {
492
+ changed = true;
493
+ return { ...prompt, status: "queued" };
494
+ }
495
+ return prompt;
496
+ });
497
+ if (changed) {
498
+ entry.snapshot.updatedAt = new Date().toISOString();
499
+ this.schedulePersist();
500
+ this.emitSessionUpdated(entry);
501
+ }
502
+ return changed;
503
+ }
504
+ async startEngine(entry, resume) {
505
+ entry.snapshot.connectionState = "connecting";
506
+ entry.snapshot.updatedAt = new Date().toISOString();
507
+ this.schedulePersist();
508
+ if (entry.snapshot.engine === "cli") {
509
+ await this.startCliSession(entry, resume);
510
+ return;
511
+ }
512
+ await this.startAcpSession(entry, resume);
513
+ }
514
+ async stopEngine(entry, reason) {
515
+ entry.acpQueueDrainActive = false;
516
+ if (reason === "switch") {
517
+ entry.outputBuffer = "";
518
+ }
519
+ if (entry.cliSession) {
520
+ entry.cliExitExpected = true;
521
+ entry.cliSession.kill();
522
+ entry.cliSession = null;
523
+ }
524
+ if (entry.acpSession) {
525
+ await entry.acpSession.dispose();
526
+ entry.acpSession = null;
527
+ }
528
+ }
188
529
  async startCliSession(entry, resume) {
189
530
  const { snapshot } = entry;
531
+ if (!snapshot.sessionId) {
532
+ snapshot.sessionId = randomUUID();
533
+ this.bindSessionId(snapshot.clientSessionId, "", snapshot.sessionId, snapshot.workspacePath, false);
534
+ }
190
535
  try {
191
536
  const cliSession = new ClaudeCliSession({
192
537
  workspacePath: snapshot.workspacePath,
@@ -196,8 +541,8 @@ export class SessionManager {
196
541
  allowSkipPermissions: snapshot.allowSkipPermissions,
197
542
  });
198
543
  entry.cliSession = cliSession;
544
+ entry.cliExitExpected = false;
199
545
  cliSession.on("output", (data) => {
200
- // Append to ring buffer so reconnecting clients can replay history
201
546
  entry.outputBuffer += data;
202
547
  if (entry.outputBuffer.length > SessionManager.OUTPUT_BUFFER_MAX) {
203
548
  entry.outputBuffer = entry.outputBuffer.slice(-SessionManager.OUTPUT_BUFFER_MAX);
@@ -208,6 +553,12 @@ export class SessionManager {
208
553
  });
209
554
  });
210
555
  cliSession.on("exit", (exitCode) => {
556
+ const expected = entry.cliExitExpected;
557
+ entry.cliExitExpected = false;
558
+ entry.cliSession = null;
559
+ if (expected) {
560
+ return;
561
+ }
211
562
  this.clearDiscoveryTimer(snapshot.clientSessionId);
212
563
  snapshot.connectionState = "error";
213
564
  snapshot.updatedAt = new Date().toISOString();
@@ -226,11 +577,572 @@ export class SessionManager {
226
577
  throw error;
227
578
  }
228
579
  }
580
+ async startAcpSession(entry, resume) {
581
+ if (!this.agentBinPath) {
582
+ throw new Error("ACP engine is unavailable.");
583
+ }
584
+ const acpSession = new ClaudeAcpSession({
585
+ workspacePath: entry.snapshot.workspacePath,
586
+ agentBinPath: this.agentBinPath,
587
+ claudeBin: this.claudeBin,
588
+ onEvent: (event) => this.handleAcpSessionEvent(entry.snapshot.clientSessionId, event),
589
+ });
590
+ entry.acpSession = acpSession;
591
+ await acpSession.connect();
592
+ if (resume && entry.snapshot.sessionId) {
593
+ entry.acpFullTimeline = [];
594
+ this.syncVisibleTimeline(entry);
595
+ const restorableSessionId = await acpSession.findRestorableSession(entry.snapshot.sessionId);
596
+ if (restorableSessionId) {
597
+ if (restorableSessionId !== entry.snapshot.sessionId) {
598
+ this.bindSessionId(entry.snapshot.clientSessionId, entry.snapshot.sessionId, restorableSessionId, entry.snapshot.workspacePath, true);
599
+ }
600
+ await acpSession.loadSession(restorableSessionId);
601
+ }
602
+ else {
603
+ await acpSession.ensureSession();
604
+ }
605
+ }
606
+ else {
607
+ await acpSession.ensureSession();
608
+ }
609
+ const acpState = this.ensureAcpState(entry);
610
+ if (acpState.defaultModeId && acpState.currentModeId !== acpState.defaultModeId) {
611
+ await acpSession.setMode(acpState.defaultModeId);
612
+ acpState.currentModeId = acpState.defaultModeId;
613
+ }
614
+ entry.snapshot.connectionState = "connected";
615
+ entry.snapshot.updatedAt = new Date().toISOString();
616
+ this.schedulePersist();
617
+ this.recoverSendingQueuedPrompts(entry);
618
+ void this.drainAcpPromptQueue(entry);
619
+ }
620
+ handleAcpSessionEvent(clientSessionId, event) {
621
+ const entry = this.sessions.get(clientSessionId);
622
+ if (!entry) {
623
+ return;
624
+ }
625
+ const acpState = this.ensureAcpState(entry);
626
+ let shouldEmitFullSnapshot = false;
627
+ switch (event.type) {
628
+ case "ready":
629
+ entry.snapshot.connectionState = "connected";
630
+ entry.snapshot.workspacePath = event.payload.workspacePath;
631
+ this.appendTimeline(entry, {
632
+ id: randomUUID(),
633
+ kind: "system",
634
+ title: "Claude ACP 已连接",
635
+ body: event.payload.workspacePath,
636
+ });
637
+ shouldEmitFullSnapshot = true;
638
+ break;
639
+ case "session_created":
640
+ this.bindSessionId(clientSessionId, entry.snapshot.sessionId, event.payload.sessionId, entry.snapshot.workspacePath, false);
641
+ entry.snapshot.connectionState = "connected";
642
+ acpState.modes = event.payload.modes;
643
+ acpState.currentModeId = acpState.currentModeId || acpState.defaultModeId || "default";
644
+ this.appendTimeline(entry, {
645
+ id: randomUUID(),
646
+ kind: "system",
647
+ title: "会话已创建",
648
+ body: event.payload.sessionId,
649
+ meta: labelForMode(acpState.currentModeId || acpState.defaultModeId),
650
+ });
651
+ shouldEmitFullSnapshot = true;
652
+ break;
653
+ case "session_restored":
654
+ this.bindSessionId(clientSessionId, entry.snapshot.sessionId, event.payload.sessionId, entry.snapshot.workspacePath, false);
655
+ entry.snapshot.connectionState = "connected";
656
+ acpState.modes = event.payload.modes;
657
+ this.appendTimeline(entry, {
658
+ id: randomUUID(),
659
+ kind: "system",
660
+ title: "会话已恢复",
661
+ body: event.payload.sessionId,
662
+ meta: labelForMode(acpState.currentModeId || acpState.defaultModeId),
663
+ });
664
+ shouldEmitFullSnapshot = true;
665
+ break;
666
+ case "prompt_started":
667
+ acpState.busy = true;
668
+ this.appendTimeline(entry, {
669
+ id: event.payload.promptId,
670
+ kind: "user",
671
+ title: "你",
672
+ body: event.payload.text,
673
+ images: event.payload.images,
674
+ });
675
+ break;
676
+ case "prompt_finished":
677
+ acpState.busy = shouldKeepAcpSessionRunningAfterPromptFinished(event.payload.stopReason);
678
+ if (acpState.queuedPrompts[0]?.status === "sending") {
679
+ acpState.queuedPrompts.shift();
680
+ shouldEmitFullSnapshot = true;
681
+ }
682
+ this.appendTimeline(entry, {
683
+ id: randomUUID(),
684
+ kind: "system",
685
+ title: acpState.busy ? "等待待处理中" : "本轮完成",
686
+ body: event.payload.stopReason,
687
+ });
688
+ break;
689
+ case "session_update":
690
+ this.consumeSessionUpdate(entry, event.payload);
691
+ break;
692
+ case "permission_requested": {
693
+ const normalizedTitle = normalizeAcpToolTitle(event.payload.toolCall.title) || undefined;
694
+ if (isAskUserQuestionTitle(normalizedTitle)) {
695
+ const rawInput = asRecord(event.payload.toolCall.rawInput);
696
+ const rawQuestions = Array.isArray(rawInput?.questions) ? rawInput.questions : [];
697
+ const parsedQuestions = [];
698
+ if (rawQuestions.length > 0) {
699
+ for (const rawQ of rawQuestions) {
700
+ const q = asRecord(rawQ);
701
+ if (!q)
702
+ continue;
703
+ const questionStr = typeof q.question === "string" ? q.question : "";
704
+ const headerStr = typeof q.header === "string" ? q.header : undefined;
705
+ const rawOpts = Array.isArray(q.options) ? q.options : [];
706
+ const options = rawOpts
707
+ .map((opt) => {
708
+ const o = asRecord(opt);
709
+ if (!o)
710
+ return null;
711
+ const label = typeof o.label === "string" ? o.label : "";
712
+ const description = typeof o.description === "string" ? o.description : undefined;
713
+ return label ? { id: label, label, description } : null;
714
+ })
715
+ .filter((o) => o !== null);
716
+ parsedQuestions.push({
717
+ question: questionStr,
718
+ header: headerStr,
719
+ options,
720
+ allowCustomAnswer: true,
721
+ });
722
+ }
723
+ }
724
+ else {
725
+ const questionText = typeof rawInput?.question === "string" ? rawInput.question : "";
726
+ parsedQuestions.push({
727
+ question: questionText,
728
+ header: undefined,
729
+ options: [],
730
+ allowCustomAnswer: true,
731
+ });
732
+ }
733
+ const groupId = randomUUID();
734
+ const questionIds = [];
735
+ for (const pq of parsedQuestions) {
736
+ const questionId = randomUUID();
737
+ questionIds.push(questionId);
738
+ const questionSnapshot = {
739
+ clientSessionId,
740
+ questionId,
741
+ groupId,
742
+ question: pq.question,
743
+ header: pq.header,
744
+ options: pq.options,
745
+ allowCustomAnswer: true,
746
+ };
747
+ acpState.questions.push(questionSnapshot);
748
+ this.appendTimeline(entry, {
749
+ id: questionId,
750
+ kind: "system",
751
+ title: "提问",
752
+ body: pq.header ? `【${pq.header}】${pq.question}` : pq.question,
753
+ meta: "pending",
754
+ });
755
+ this.askUserQuestionMap.set(questionId, {
756
+ clientSessionId,
757
+ requestId: event.payload.requestId,
758
+ });
759
+ }
760
+ entry.snapshot.updatedAt = new Date().toISOString();
761
+ this.schedulePersist();
762
+ for (let i = 0; i < parsedQuestions.length; i += 1) {
763
+ const pq = parsedQuestions[i];
764
+ this.emit({
765
+ type: "question_requested",
766
+ payload: {
767
+ clientSessionId,
768
+ questionId: questionIds[i],
769
+ groupId,
770
+ question: pq.question,
771
+ header: pq.header,
772
+ options: pq.options,
773
+ allowCustomAnswer: true,
774
+ },
775
+ });
776
+ }
777
+ return;
778
+ }
779
+ const permission = {
780
+ clientSessionId,
781
+ requestId: event.payload.requestId,
782
+ toolCall: {
783
+ toolCallId: event.payload.toolCall.toolCallId,
784
+ title: normalizedTitle,
785
+ status: event.payload.toolCall.status ?? undefined,
786
+ rawInput: event.payload.toolCall.rawInput,
787
+ },
788
+ options: event.payload.options.map((option) => ({
789
+ optionId: option.optionId,
790
+ name: option.name,
791
+ kind: option.kind,
792
+ })),
793
+ };
794
+ acpState.permissions.push(permission);
795
+ this.appendTimeline(entry, {
796
+ id: event.payload.requestId,
797
+ kind: "tool",
798
+ title: summarizeToolTitle(normalizedTitle, event.payload.toolCall.rawInput, event.payload.toolCall.toolCallId),
799
+ body: formatToolDetails({
800
+ toolCallId: event.payload.toolCall.toolCallId,
801
+ title: normalizedTitle,
802
+ status: event.payload.toolCall.status,
803
+ rawInput: event.payload.toolCall.rawInput,
804
+ }),
805
+ meta: event.payload.toolCall.status ?? "pending",
806
+ });
807
+ break;
808
+ }
809
+ case "permission_resolved":
810
+ acpState.permissions = acpState.permissions.filter((permission) => permission.requestId !== event.payload.requestId);
811
+ entry.snapshot.acp = { ...acpState };
812
+ break;
813
+ case "question_requested": {
814
+ const questionSnapshot = {
815
+ clientSessionId,
816
+ questionId: event.payload.questionId,
817
+ question: event.payload.question,
818
+ options: event.payload.options.map((opt) => ({
819
+ id: opt.id,
820
+ label: opt.label,
821
+ })),
822
+ allowCustomAnswer: event.payload.allowCustomAnswer,
823
+ };
824
+ acpState.questions.push(questionSnapshot);
825
+ this.appendTimeline(entry, {
826
+ id: event.payload.questionId,
827
+ kind: "system",
828
+ title: "提问",
829
+ body: event.payload.question,
830
+ meta: "pending",
831
+ });
832
+ break;
833
+ }
834
+ case "question_answered":
835
+ acpState.questions = acpState.questions.filter((question) => question.questionId !== event.payload.questionId);
836
+ entry.snapshot.acp = { ...acpState };
837
+ break;
838
+ case "error": {
839
+ const editChangeMessage = formatEditToolChangeMessage(event.payload.message);
840
+ if (editChangeMessage) {
841
+ this.appendTimeline(entry, {
842
+ id: randomUUID(),
843
+ kind: "tool",
844
+ title: editChangeMessage.title,
845
+ body: editChangeMessage.body,
846
+ meta: "completed",
847
+ });
848
+ break;
849
+ }
850
+ if (event.payload.fatal) {
851
+ acpState.busy = false;
852
+ entry.snapshot.connectionState = "error";
853
+ shouldEmitFullSnapshot = true;
854
+ }
855
+ this.appendTimeline(entry, {
856
+ id: randomUUID(),
857
+ kind: "error",
858
+ title: event.payload.fatal ? "错误" : "警告",
859
+ body: event.payload.message,
860
+ });
861
+ break;
862
+ }
863
+ }
864
+ entry.snapshot.updatedAt = new Date().toISOString();
865
+ this.schedulePersist();
866
+ if (shouldEmitFullSnapshot) {
867
+ this.emitSessionUpdated(entry);
868
+ }
869
+ switch (event.type) {
870
+ case "prompt_started":
871
+ this.emit({
872
+ type: "prompt_started",
873
+ payload: {
874
+ clientSessionId,
875
+ promptId: event.payload.promptId,
876
+ text: event.payload.text,
877
+ images: event.payload.images,
878
+ },
879
+ });
880
+ break;
881
+ case "prompt_finished":
882
+ this.emit({
883
+ type: "prompt_finished",
884
+ payload: {
885
+ clientSessionId,
886
+ promptId: event.payload.promptId,
887
+ stopReason: event.payload.stopReason,
888
+ },
889
+ });
890
+ break;
891
+ case "session_update":
892
+ this.emit({
893
+ type: "session_update",
894
+ payload: {
895
+ clientSessionId,
896
+ ...event.payload,
897
+ sessionUpdate: event.payload.sessionUpdate,
898
+ },
899
+ });
900
+ break;
901
+ case "permission_requested":
902
+ this.emit({
903
+ type: "permission_requested",
904
+ payload: {
905
+ clientSessionId,
906
+ requestId: event.payload.requestId,
907
+ toolCall: {
908
+ toolCallId: event.payload.toolCall.toolCallId,
909
+ title: event.payload.toolCall.title ?? undefined,
910
+ status: event.payload.toolCall.status ?? undefined,
911
+ rawInput: event.payload.toolCall.rawInput,
912
+ },
913
+ options: event.payload.options.map((option) => ({
914
+ optionId: option.optionId,
915
+ name: option.name,
916
+ kind: option.kind,
917
+ })),
918
+ },
919
+ });
920
+ break;
921
+ case "permission_resolved":
922
+ this.emit({
923
+ type: "permission_resolved",
924
+ payload: {
925
+ clientSessionId,
926
+ requestId: event.payload.requestId,
927
+ optionId: event.payload.optionId,
928
+ },
929
+ });
930
+ break;
931
+ case "question_requested":
932
+ this.emit({
933
+ type: "question_requested",
934
+ payload: {
935
+ clientSessionId,
936
+ questionId: event.payload.questionId,
937
+ question: event.payload.question,
938
+ options: event.payload.options.map((option) => ({
939
+ id: option.id,
940
+ label: option.label,
941
+ })),
942
+ allowCustomAnswer: event.payload.allowCustomAnswer,
943
+ },
944
+ });
945
+ break;
946
+ case "question_answered":
947
+ this.emit({
948
+ type: "question_answered",
949
+ payload: {
950
+ clientSessionId,
951
+ questionId: event.payload.questionId,
952
+ answer: event.payload.answer,
953
+ },
954
+ });
955
+ break;
956
+ case "error":
957
+ this.emit({
958
+ type: "error",
959
+ payload: {
960
+ clientSessionId,
961
+ message: event.payload.message,
962
+ fatal: event.payload.fatal,
963
+ },
964
+ });
965
+ break;
966
+ default:
967
+ break;
968
+ }
969
+ if (event.type === "prompt_finished" || event.type === "permission_resolved" || event.type === "question_answered") {
970
+ void this.drainAcpPromptQueue(entry);
971
+ }
972
+ }
973
+ consumeSessionUpdate(entry, update) {
974
+ const acpState = this.ensureAcpState(entry);
975
+ switch (update.sessionUpdate) {
976
+ case "available_commands_update":
977
+ acpState.availableCommands = normalizeAvailableCommandsSnapshot(update.availableCommands ?? update.supportedCommands ?? update.commands);
978
+ entry.snapshot.acp = { ...acpState };
979
+ break;
980
+ case "agent_message_chunk": {
981
+ const chunkText = extractChunkText(update.content);
982
+ if (chunkText) {
983
+ const parentId = extractParentToolCallId(update);
984
+ this.appendTextChunk(entry, "agent", "Claude", chunkText, parentId);
985
+ }
986
+ break;
987
+ }
988
+ case "agent_thought_chunk": {
989
+ const chunkText = extractChunkText(update.content);
990
+ if (chunkText) {
991
+ const parentId = extractParentToolCallId(update);
992
+ this.appendTextChunk(entry, "thought", "思路", chunkText, parentId);
993
+ }
994
+ break;
995
+ }
996
+ case "tool_call":
997
+ case "tool_call_update": {
998
+ const claudeCodeMeta = asRecord(asRecord(update._meta)?.claudeCode);
999
+ const metaToolName = typeof claudeCodeMeta?.toolName === "string" ? claudeCodeMeta.toolName : undefined;
1000
+ const normalizedTitle = normalizeAcpToolTitle(update.title) || normalizeAcpToolTitle(metaToolName) || undefined;
1001
+ const parentToolCallId = extractParentToolCallId(update);
1002
+ const effectiveStatus = isAskUserQuestionTitle(normalizedTitle) && update.status === "failed"
1003
+ ? "completed"
1004
+ : update.status;
1005
+ this.appendTimeline(entry, {
1006
+ id: randomUUID(),
1007
+ kind: "tool",
1008
+ title: summarizeToolTitle(normalizedTitle, update.rawInput, update.toolCallId),
1009
+ body: formatToolDetails({
1010
+ toolCallId: update.toolCallId,
1011
+ title: normalizedTitle,
1012
+ status: effectiveStatus,
1013
+ rawInput: update.rawInput,
1014
+ rawOutput: update.rawOutput,
1015
+ parentToolCallId,
1016
+ }),
1017
+ meta: String(effectiveStatus ?? update.sessionUpdate),
1018
+ });
1019
+ break;
1020
+ }
1021
+ case "plan": {
1022
+ const parentToolCallId = extractParentToolCallId(update);
1023
+ this.appendTimeline(entry, {
1024
+ id: randomUUID(),
1025
+ kind: "system",
1026
+ title: "执行计划",
1027
+ body: stringifyMaybe(update.entries ?? update),
1028
+ parentToolCallId,
1029
+ });
1030
+ break;
1031
+ }
1032
+ case "current_mode_update":
1033
+ acpState.currentModeId = String(update.currentModeId ?? acpState.currentModeId ?? "default");
1034
+ this.appendTimeline(entry, {
1035
+ id: randomUUID(),
1036
+ kind: "system",
1037
+ title: "模式切换",
1038
+ body: String(update.currentModeId ?? "unknown"),
1039
+ });
1040
+ break;
1041
+ default:
1042
+ break;
1043
+ }
1044
+ }
1045
+ ensureAcpState(entry) {
1046
+ if (!entry.snapshot.acp) {
1047
+ entry.snapshot.acp = createEmptyAcpState();
1048
+ }
1049
+ return entry.snapshot.acp;
1050
+ }
1051
+ appendTextChunk(entry, kind, title, text, parentToolCallId) {
1052
+ const fullTimeline = this.ensureFullTimeline(entry);
1053
+ const lastItem = fullTimeline.at(-1);
1054
+ if (lastItem &&
1055
+ lastItem.kind === kind &&
1056
+ lastItem.title === title &&
1057
+ !lastItem.meta &&
1058
+ (lastItem.parentToolCallId ?? undefined) === parentToolCallId) {
1059
+ lastItem.body += text;
1060
+ this.syncVisibleTimeline(entry);
1061
+ return;
1062
+ }
1063
+ this.appendTimeline(entry, {
1064
+ id: randomUUID(),
1065
+ kind,
1066
+ title,
1067
+ body: text,
1068
+ parentToolCallId,
1069
+ });
1070
+ }
1071
+ appendTimeline(entry, item) {
1072
+ this.ensureFullTimeline(entry).push(item);
1073
+ this.syncVisibleTimeline(entry);
1074
+ }
1075
+ syncVisibleTimeline(entry) {
1076
+ const acpState = this.ensureAcpState(entry);
1077
+ const fullTimeline = this.ensureFullTimeline(entry);
1078
+ const total = fullTimeline.length;
1079
+ const start = Math.max(0, total - SessionManager.INITIAL_TIMELINE_WINDOW);
1080
+ acpState.timeline = fullTimeline.slice(start);
1081
+ acpState.historyTotal = total;
1082
+ acpState.historyStart = start;
1083
+ }
1084
+ ensureFullTimeline(entry) {
1085
+ if (!Array.isArray(entry.acpFullTimeline)) {
1086
+ entry.acpFullTimeline = [];
1087
+ }
1088
+ return entry.acpFullTimeline;
1089
+ }
229
1090
  emit(event) {
230
1091
  for (const listener of this.listeners) {
231
1092
  listener(event);
232
1093
  }
233
1094
  }
1095
+ emitSessionUpdated(entry) {
1096
+ this.emit({
1097
+ type: "session_updated",
1098
+ payload: this.snapshotForEvent(entry),
1099
+ });
1100
+ }
1101
+ snapshotForEvent(entry) {
1102
+ const blockedReason = this.getSwitchBlockedReason(entry);
1103
+ const snapshot = structuredClone(entry.snapshot);
1104
+ snapshot.switchable = !blockedReason;
1105
+ snapshot.switchBlockedReason = blockedReason ?? undefined;
1106
+ return snapshot;
1107
+ }
1108
+ getSwitchBlockedReason(entry) {
1109
+ if (entry.switchInProgress) {
1110
+ return "切换中";
1111
+ }
1112
+ if (entry.snapshot.connectionState === "connecting") {
1113
+ return "连接中";
1114
+ }
1115
+ if (entry.snapshot.engine === "cli") {
1116
+ if (entry.snapshot.activityState === "running")
1117
+ return "运行中";
1118
+ if (entry.snapshot.activityState === "pending")
1119
+ return "待处理";
1120
+ return null;
1121
+ }
1122
+ const acpState = entry.snapshot.acp;
1123
+ if (!acpState) {
1124
+ return null;
1125
+ }
1126
+ if (acpState.permissions.length > 0)
1127
+ return "待审批";
1128
+ if (acpState.questions.length > 0)
1129
+ return "待提问";
1130
+ if (this.isAcpBusy(acpState))
1131
+ return "运行中";
1132
+ if (acpState.queuedPrompts.length > 0)
1133
+ return "队列未清空";
1134
+ return null;
1135
+ }
1136
+ isAcpBusy(acpState) {
1137
+ if (!acpState.busy) {
1138
+ return false;
1139
+ }
1140
+ const latestItem = acpState.timeline.at(-1);
1141
+ if (latestItem?.kind === "system" && latestItem.title === "本轮完成") {
1142
+ return false;
1143
+ }
1144
+ return true;
1145
+ }
234
1146
  schedulePersist() {
235
1147
  if (this.persistTimer) {
236
1148
  clearTimeout(this.persistTimer);
@@ -246,8 +1158,12 @@ export class SessionManager {
246
1158
  title: session.title,
247
1159
  workspacePath: session.workspacePath,
248
1160
  sessionId: session.sessionId,
1161
+ engine: session.engine,
249
1162
  updatedAt: session.updatedAt,
250
1163
  allowSkipPermissions: session.allowSkipPermissions,
1164
+ acpDefaultModeId: session.acp?.defaultModeId,
1165
+ acpCurrentModeId: session.acp?.currentModeId,
1166
+ acpQueuedPrompts: session.acp?.queuedPrompts ?? [],
251
1167
  }));
252
1168
  await mkdir(path.dirname(this.stateFilePath), { recursive: true });
253
1169
  await writeFile(this.stateFilePath, JSON.stringify({ sessions: persistedSessions }, null, 2), "utf8");
@@ -297,43 +1213,59 @@ export class SessionManager {
297
1213
  return false;
298
1214
  }
299
1215
  }
300
- // ---------------------------------------------------------------------------
301
- // /clear detection session ID discovery
302
- // ---------------------------------------------------------------------------
1216
+ bindSessionId(clientSessionId, oldSessionId, newSessionId, workspacePath, emitChangeEvent) {
1217
+ if (!newSessionId || oldSessionId === newSessionId) {
1218
+ if (newSessionId) {
1219
+ this.sessionIdIndex.set(newSessionId, clientSessionId);
1220
+ }
1221
+ return;
1222
+ }
1223
+ if (oldSessionId) {
1224
+ this.sessionIdIndex.delete(oldSessionId);
1225
+ this.activityMonitor.switchWatch(oldSessionId, newSessionId, workspacePath);
1226
+ }
1227
+ else {
1228
+ this.activityMonitor.watch(newSessionId, workspacePath);
1229
+ }
1230
+ this.sessionIdIndex.set(newSessionId, clientSessionId);
1231
+ const entry = this.sessions.get(clientSessionId);
1232
+ if (entry) {
1233
+ entry.snapshot.sessionId = newSessionId;
1234
+ entry.snapshot.updatedAt = new Date().toISOString();
1235
+ this.schedulePersist();
1236
+ }
1237
+ if (emitChangeEvent) {
1238
+ this.emit({
1239
+ type: "session_id_updated",
1240
+ payload: { clientSessionId, newSessionId },
1241
+ });
1242
+ }
1243
+ }
303
1244
  handlePlanSessionDetected(newSessionId, promptId, workspacePath) {
304
1245
  const oldSessionId = this.activityMonitor.getSessionIdByPromptId(promptId);
305
1246
  if (!oldSessionId) {
306
- console.log(`[SessionManager] handlePlanSessionDetected: no oldSessionId found for promptId ${promptId}`);
307
1247
  return;
308
1248
  }
309
1249
  const clientSessionId = this.sessionIdIndex.get(oldSessionId);
310
1250
  if (!clientSessionId) {
311
- console.log(`[SessionManager] handlePlanSessionDetected: no clientSessionId found for oldSessionId ${oldSessionId}`);
312
1251
  return;
313
1252
  }
314
- console.log(`[SessionManager] Plan session detected: ${oldSessionId} → ${newSessionId} (promptId=${promptId})`);
315
1253
  this.completeSessionSwitch(clientSessionId, oldSessionId, newSessionId, workspacePath);
316
1254
  }
317
1255
  handleSessionClear(oldSessionId, workspacePath) {
318
1256
  const clientSessionId = this.sessionIdIndex.get(oldSessionId);
319
1257
  if (!clientSessionId) {
320
- console.log(`[SessionManager] handleSessionClear: no clientSessionId found for ${oldSessionId}`);
321
1258
  return;
322
1259
  }
323
1260
  const entry = this.sessions.get(clientSessionId);
324
1261
  if (!entry) {
325
- console.log(`[SessionManager] handleSessionClear: no entry found for clientSessionId ${clientSessionId}`);
326
1262
  return;
327
1263
  }
328
- console.log(`[SessionManager] handleSessionClear: oldSessionId=${oldSessionId}, clientSessionId=${clientSessionId}, workspace=${workspacePath}`);
329
- // Immediately mark as idle — the old session is done
330
1264
  entry.snapshot.activityState = "idle";
331
1265
  this.emit({
332
1266
  type: "session_activity",
333
1267
  payload: { clientSessionId, activityState: "idle" },
334
1268
  });
335
- // Delay 500ms before starting discovery — the new session file is created
336
- // almost simultaneously with /clear in history.jsonl.
337
1269
  const delayTimer = setTimeout(() => {
338
1270
  this.startNewSessionDiscovery(clientSessionId, oldSessionId, workspacePath);
339
1271
  }, 500);
@@ -342,44 +1274,40 @@ export class SessionManager {
342
1274
  startNewSessionDiscovery(clientSessionId, oldSessionId, workspacePath) {
343
1275
  const dirPath = projectDirPath(workspacePath);
344
1276
  let pollCount = 0;
345
- console.log(`[SessionManager] startNewSessionDiscovery: dir=${dirPath}, oldSession=${oldSessionId}`);
346
1277
  const tryFind = async () => {
347
1278
  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
1279
+ const files = (await readdir(dirPath)).filter((file) => file.endsWith(".jsonl"));
350
1280
  const candidates = [];
351
- for (const f of files) {
352
- const sid = f.replace(/\.jsonl$/, "");
1281
+ for (const file of files) {
1282
+ const sid = file.replace(/\.jsonl$/, "");
353
1283
  if (sid === oldSessionId)
354
1284
  continue;
355
1285
  try {
356
- const s = await stat(path.join(dirPath, f));
357
- candidates.push({ name: f, mtimeMs: s.mtimeMs });
1286
+ const fileStat = await stat(path.join(dirPath, file));
1287
+ candidates.push({ name: file, mtimeMs: fileStat.mtimeMs });
358
1288
  }
359
1289
  catch {
360
- // skip unreadable
1290
+ // ignore unreadable candidate
361
1291
  }
362
1292
  }
363
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
364
- // Check the top 3 newest files for /clear content
1293
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
365
1294
  for (const candidate of candidates.slice(0, 3)) {
366
1295
  const filePath = path.join(dirPath, candidate.name);
367
1296
  let fd;
368
1297
  try {
369
1298
  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);
1299
+ const buffer = Buffer.alloc(4096);
1300
+ const { bytesRead } = await fd.read(buffer, 0, 4096, 0);
1301
+ const head = buffer.toString("utf8", 0, bytesRead);
373
1302
  if (head.includes("<command-name>/clear</command-name>")) {
374
1303
  const newSessionId = candidate.name.replace(/\.jsonl$/, "");
375
- console.log(`[SessionManager] discovery confirmed new session: ${candidate.name} (contains /clear)`);
376
1304
  this.clearDiscoveryTimer(clientSessionId);
377
1305
  this.completeSessionSwitch(clientSessionId, oldSessionId, newSessionId, workspacePath);
378
1306
  return true;
379
1307
  }
380
1308
  }
381
1309
  catch {
382
- // skip
1310
+ // ignore
383
1311
  }
384
1312
  finally {
385
1313
  await fd?.close();
@@ -387,18 +1315,16 @@ export class SessionManager {
387
1315
  }
388
1316
  }
389
1317
  catch {
390
- // Directory may not exist yet
1318
+ // ignore
391
1319
  }
392
1320
  return false;
393
1321
  };
394
- // Immediate first attempt, then retry every 1s up to ~1 minute
395
1322
  tryFind().then((found) => {
396
1323
  if (found)
397
1324
  return;
398
1325
  const timer = setInterval(async () => {
399
- pollCount++;
1326
+ pollCount += 1;
400
1327
  if (pollCount > SessionManager.DISCOVERY_MAX_POLLS) {
401
- console.log(`[SessionManager] discovery timed out for ${oldSessionId}`);
402
1328
  this.clearDiscoveryTimer(clientSessionId);
403
1329
  return;
404
1330
  }
@@ -411,22 +1337,9 @@ export class SessionManager {
411
1337
  const entry = this.sessions.get(clientSessionId);
412
1338
  if (!entry)
413
1339
  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;
1340
+ this.bindSessionId(clientSessionId, oldSessionId, newSessionId, workspacePath, true);
420
1341
  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
1342
  this.schedulePersist();
425
- // Notify frontend
426
- this.emit({
427
- type: "session_id_updated",
428
- payload: { clientSessionId, newSessionId },
429
- });
430
1343
  }
431
1344
  clearDiscoveryTimer(clientSessionId) {
432
1345
  const timer = this.discoveryTimers.get(clientSessionId);
@@ -435,25 +1348,19 @@ export class SessionManager {
435
1348
  this.discoveryTimers.delete(clientSessionId);
436
1349
  }
437
1350
  }
438
- // ---------------------------------------------------------------------------
439
- // history.jsonl monitoring — detect /clear commands globally
440
- // ---------------------------------------------------------------------------
441
1351
  async startHistoryMonitor() {
442
- // Get initial file size so we only process new lines
443
1352
  try {
444
- const stats = await stat(this.historyFilePath);
445
- this.lastHistorySize = stats.size;
1353
+ const historyStats = await stat(this.historyFilePath);
1354
+ this.lastHistorySize = historyStats.size;
446
1355
  }
447
1356
  catch {
448
1357
  this.lastHistorySize = 0;
449
1358
  }
450
- console.log(`[SessionManager] Watching ${this.historyFilePath} for /clear commands (initial size=${this.lastHistorySize})`);
451
1359
  try {
452
1360
  this.historyWatcher = watch(this.historyFilePath, () => {
453
1361
  this.scheduleHistoryCheck();
454
1362
  });
455
1363
  this.historyWatcher.on("error", () => {
456
- // Fall back to polling if the watcher fails
457
1364
  this.historyWatcher?.close();
458
1365
  this.historyWatcher = null;
459
1366
  if (!this.historyPollTimer) {
@@ -464,7 +1371,6 @@ export class SessionManager {
464
1371
  });
465
1372
  }
466
1373
  catch {
467
- // Watcher unavailable — use polling
468
1374
  this.historyPollTimer = setInterval(() => {
469
1375
  this.checkHistoryUpdates().catch(() => undefined);
470
1376
  }, SessionManager.HISTORY_POLL_MS);
@@ -481,68 +1387,54 @@ export class SessionManager {
481
1387
  async checkHistoryUpdates() {
482
1388
  let fd;
483
1389
  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;
1390
+ const historyStats = await stat(this.historyFilePath);
1391
+ if (historyStats.size < this.lastHistorySize) {
1392
+ this.lastHistorySize = historyStats.size;
488
1393
  return;
489
1394
  }
490
- // No new content
491
- if (stats.size === this.lastHistorySize)
1395
+ if (historyStats.size === this.lastHistorySize)
492
1396
  return;
493
- const readSize = stats.size - this.lastHistorySize;
1397
+ const readSize = historyStats.size - this.lastHistorySize;
494
1398
  fd = await open(this.historyFilePath, "r");
495
1399
  const buffer = Buffer.alloc(readSize);
496
1400
  await fd.read(buffer, 0, readSize, this.lastHistorySize);
497
- this.lastHistorySize = stats.size;
1401
+ this.lastHistorySize = historyStats.size;
498
1402
  const newText = buffer.toString("utf8");
499
- const lines = newText.split("\n").filter((l) => l.trim().length > 0);
1403
+ const lines = newText.split("\n").filter((line) => line.trim().length > 0);
500
1404
  for (const line of lines) {
501
1405
  try {
502
1406
  const entry = JSON.parse(line);
503
1407
  if (entry.display === "/clear" && entry.sessionId) {
504
- // Check if we're tracking this session
505
1408
  const clientSessionId = this.sessionIdIndex.get(entry.sessionId);
506
1409
  if (clientSessionId) {
507
1410
  const managed = this.sessions.get(clientSessionId);
508
1411
  if (managed) {
509
- console.log(`[SessionManager] /clear detected in history.jsonl for session ${entry.sessionId} (client=${clientSessionId})`);
510
1412
  this.handleSessionClear(entry.sessionId, managed.snapshot.workspacePath);
511
1413
  }
512
1414
  }
513
1415
  }
514
1416
  }
515
1417
  catch {
516
- // Skip malformed lines
1418
+ // ignore malformed line
517
1419
  }
518
1420
  }
519
1421
  }
520
1422
  catch {
521
- // File doesn't exist or unreadable — will retry on next trigger
1423
+ // ignore
522
1424
  }
523
1425
  finally {
524
1426
  await fd?.close();
525
1427
  }
526
1428
  }
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
1429
  handleManagerError(clientSessionId, error) {
542
1430
  const entry = this.sessions.get(clientSessionId);
543
1431
  if (!entry) {
544
1432
  return;
545
1433
  }
1434
+ const acpState = entry.snapshot.acp;
1435
+ if (acpState) {
1436
+ acpState.busy = false;
1437
+ }
546
1438
  entry.snapshot.connectionState = "error";
547
1439
  entry.snapshot.updatedAt = new Date().toISOString();
548
1440
  this.schedulePersist();
@@ -556,6 +1448,205 @@ export class SessionManager {
556
1448
  });
557
1449
  }
558
1450
  }
1451
+ function createEmptyAcpState(defaultModeId = "default", currentModeId = defaultModeId, queuedPrompts = []) {
1452
+ return {
1453
+ modes: [],
1454
+ defaultModeId,
1455
+ currentModeId,
1456
+ busy: false,
1457
+ timeline: [],
1458
+ historyTotal: 0,
1459
+ historyStart: 0,
1460
+ permissions: [],
1461
+ questions: [],
1462
+ availableCommands: [],
1463
+ queuedPrompts,
1464
+ };
1465
+ }
1466
+ function normalizeQueuedPrompts(rawQueuedPrompts) {
1467
+ if (!Array.isArray(rawQueuedPrompts)) {
1468
+ return [];
1469
+ }
1470
+ return rawQueuedPrompts.flatMap((rawPrompt) => {
1471
+ const record = asRecord(rawPrompt);
1472
+ if (!record) {
1473
+ return [];
1474
+ }
1475
+ const id = typeof record.id === "string" && record.id.trim() ? record.id : randomUUID();
1476
+ const text = typeof record.text === "string" ? record.text : "";
1477
+ const images = Array.isArray(record.images)
1478
+ ? record.images.flatMap((rawImage) => {
1479
+ const image = asRecord(rawImage);
1480
+ if (!image || typeof image.data !== "string" || typeof image.mimeType !== "string") {
1481
+ return [];
1482
+ }
1483
+ return [{ data: image.data, mimeType: image.mimeType }];
1484
+ })
1485
+ : [];
1486
+ const modeId = typeof record.modeId === "string" && record.modeId.trim() ? record.modeId : "default";
1487
+ const createdAt = typeof record.createdAt === "string" && record.createdAt.trim()
1488
+ ? record.createdAt
1489
+ : new Date().toISOString();
1490
+ const status = record.status === "sending" ? "sending" : "queued";
1491
+ return [{ id, text, images, modeId, createdAt, status }];
1492
+ });
1493
+ }
1494
+ function shouldKeepAcpSessionRunningAfterPromptFinished(stopReason) {
1495
+ const normalized = stopReason.trim().toLowerCase();
1496
+ return normalized === "pause_turn" || normalized === "pause-turn" || normalized.includes("permission");
1497
+ }
1498
+ function isAcpPromptBusyError(error) {
1499
+ return error instanceof Error && error.message.includes("Another Claude prompt is still running.");
1500
+ }
1501
+ function stringifyMaybe(value) {
1502
+ if (typeof value === "string") {
1503
+ return value;
1504
+ }
1505
+ return JSON.stringify(value, null, 2);
1506
+ }
1507
+ function formatToolDetails(details) {
1508
+ return stringifyMaybe({
1509
+ toolCallId: details.toolCallId,
1510
+ title: details.title,
1511
+ status: details.status,
1512
+ rawInput: details.rawInput,
1513
+ rawOutput: details.rawOutput,
1514
+ ...(details.parentToolCallId ? { parentToolCallId: details.parentToolCallId } : undefined),
1515
+ });
1516
+ }
1517
+ function normalizeAcpToolTitle(rawTitle) {
1518
+ if (typeof rawTitle !== "string")
1519
+ return "";
1520
+ return rawTitle.replace(/^mcp__acp__/i, "");
1521
+ }
1522
+ function summarizeToolTitle(rawTitle, rawInput, rawToolCallId) {
1523
+ const title = normalizeAcpToolTitle(rawTitle).trim();
1524
+ const record = asRecord(rawInput) ?? asRecord(tryParseJson(rawInput));
1525
+ const normalizedTitle = title.toLowerCase();
1526
+ const isSubagent = normalizedTitle.includes("subagent") || normalizedTitle === "task" || normalizedTitle.includes(" task");
1527
+ if (isSubagent) {
1528
+ const summary = readSubagentSummary(record);
1529
+ if (summary) {
1530
+ return `${title || "Task"} · ${summary}`;
1531
+ }
1532
+ }
1533
+ if (title && !/^工具\s+tool_/.test(title) && !/^tool_/.test(title)) {
1534
+ return title;
1535
+ }
1536
+ const command = typeof record?.command === "string"
1537
+ ? record.command
1538
+ : Array.isArray(record?.cmd)
1539
+ ? record.cmd.filter((part) => typeof part === "string").join(" ")
1540
+ : null;
1541
+ const pathValue = typeof record?.path === "string"
1542
+ ? record.path
1543
+ : typeof record?.filePath === "string"
1544
+ ? record.filePath
1545
+ : typeof record?.cwd === "string"
1546
+ ? record.cwd
1547
+ : null;
1548
+ const description = typeof record?.description === "string" ? record.description : null;
1549
+ const args = Array.isArray(record?.args) ? record.args.filter((part) => typeof part === "string").join(" ") : null;
1550
+ const summary = [command, pathValue, description, args].filter(Boolean).join(" · ");
1551
+ if (summary) {
1552
+ return summary;
1553
+ }
1554
+ if (title) {
1555
+ return title;
1556
+ }
1557
+ return typeof rawToolCallId === "string" ? `工具 ${rawToolCallId}` : "工具调用";
1558
+ }
1559
+ function readSubagentSummary(record) {
1560
+ if (!record) {
1561
+ return null;
1562
+ }
1563
+ for (const key of ["title", "description"]) {
1564
+ const value = record[key];
1565
+ if (typeof value === "string" && value.trim()) {
1566
+ return value.trim();
1567
+ }
1568
+ }
1569
+ for (const key of ["rawInput", "input", "args", "payload", "params"]) {
1570
+ if (!(key in record)) {
1571
+ continue;
1572
+ }
1573
+ const nestedRecord = asRecord(record[key]) ?? asRecord(tryParseJson(record[key]));
1574
+ const nested = readSubagentSummary(nestedRecord);
1575
+ if (nested) {
1576
+ return nested;
1577
+ }
1578
+ }
1579
+ return null;
1580
+ }
1581
+ function tryParseJson(value) {
1582
+ if (typeof value !== "string") {
1583
+ return null;
1584
+ }
1585
+ const trimmed = value.trim();
1586
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
1587
+ return null;
1588
+ }
1589
+ try {
1590
+ return JSON.parse(trimmed);
1591
+ }
1592
+ catch {
1593
+ return null;
1594
+ }
1595
+ }
1596
+ function asRecord(value) {
1597
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1598
+ }
1599
+ function extractParentToolCallId(update) {
1600
+ const meta = asRecord(update._meta);
1601
+ const claudeCode = asRecord(meta?.claudeCode);
1602
+ const parentId = claudeCode?.parentToolUseId;
1603
+ return typeof parentId === "string" && parentId ? parentId : undefined;
1604
+ }
1605
+ function formatEditToolChangeMessage(message) {
1606
+ const parsed = tryParseJson(message);
1607
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1608
+ return null;
1609
+ }
1610
+ const files = parsed
1611
+ .map((entry) => asRecord(entry))
1612
+ .filter((entry) => Boolean(entry))
1613
+ .map((entry) => {
1614
+ const filePath = typeof entry.newFileName === "string"
1615
+ ? entry.newFileName
1616
+ : typeof entry.oldFileName === "string"
1617
+ ? entry.oldFileName
1618
+ : typeof entry.index === "string"
1619
+ ? entry.index
1620
+ : "";
1621
+ const hunks = Array.isArray(entry.hunks) ? entry.hunks.length : 0;
1622
+ return { filePath, hunks };
1623
+ })
1624
+ .filter((entry) => entry.filePath);
1625
+ if (files.length === 0) {
1626
+ return null;
1627
+ }
1628
+ const lines = files.map((entry) => `- ${entry.filePath}${entry.hunks > 0 ? `(${entry.hunks} 处修改)` : ""}`);
1629
+ return {
1630
+ title: `Edit 已修改 ${files.length} 个文件`,
1631
+ body: `Edit 工具已更新以下文件:\n${lines.join("\n")}`,
1632
+ };
1633
+ }
1634
+ function labelForMode(modeId) {
1635
+ switch (modeId) {
1636
+ case "default":
1637
+ return "Default";
1638
+ case "acceptEdits":
1639
+ return "Accept Edits";
1640
+ case "plan":
1641
+ return "Plan";
1642
+ case "dontAsk":
1643
+ return "Don't Ask";
1644
+ case "bypassPermissions":
1645
+ return "Bypass Permissions";
1646
+ default:
1647
+ return modeId || "默认模式";
1648
+ }
1649
+ }
559
1650
  function formatError(error) {
560
1651
  if (error instanceof Error) {
561
1652
  return error.message;
@@ -567,6 +1658,95 @@ function formatError(error) {
567
1658
  return String(error);
568
1659
  }
569
1660
  }
1661
+ function extractChunkText(content) {
1662
+ if (!content) {
1663
+ return null;
1664
+ }
1665
+ if (Array.isArray(content)) {
1666
+ const joined = content.map((item) => extractChunkText(item)).filter((item) => Boolean(item)).join("\n");
1667
+ return joined || null;
1668
+ }
1669
+ const record = asRecord(content);
1670
+ if (!record) {
1671
+ return null;
1672
+ }
1673
+ if (typeof record.text === "string" && record.text.trim()) {
1674
+ return record.text;
1675
+ }
1676
+ if (record.type === "resource") {
1677
+ const resource = asRecord(record.resource);
1678
+ if (typeof resource?.text === "string" && resource.text.trim()) {
1679
+ return resource.text;
1680
+ }
1681
+ }
1682
+ if (record.type === "resource_link") {
1683
+ const uri = typeof record.uri === "string" ? record.uri : "";
1684
+ return uri ? `[resource] ${uri}` : "[resource]";
1685
+ }
1686
+ return null;
1687
+ }
1688
+ function normalizeAvailableCommandsSnapshot(rawValue) {
1689
+ if (!Array.isArray(rawValue)) {
1690
+ return [];
1691
+ }
1692
+ const seen = new Set();
1693
+ const normalized = [];
1694
+ for (const item of rawValue) {
1695
+ if (typeof item === "string") {
1696
+ const name = normalizeCommandName(item);
1697
+ if (!name || seen.has(name)) {
1698
+ continue;
1699
+ }
1700
+ seen.add(name);
1701
+ normalized.push({ name, description: "", inputType: "unstructured" });
1702
+ continue;
1703
+ }
1704
+ const record = asRecord(item);
1705
+ const rawName = typeof record?.name === "string"
1706
+ ? record.name
1707
+ : typeof record?.command === "string"
1708
+ ? record.command
1709
+ : "";
1710
+ const name = normalizeCommandName(rawName);
1711
+ if (!name || seen.has(name)) {
1712
+ continue;
1713
+ }
1714
+ seen.add(name);
1715
+ normalized.push({
1716
+ name,
1717
+ description: typeof record?.description === "string"
1718
+ ? record.description.trim()
1719
+ : typeof record?.title === "string"
1720
+ ? record.title.trim()
1721
+ : "",
1722
+ inputType: "unstructured",
1723
+ });
1724
+ }
1725
+ return normalized.sort((left, right) => left.name.localeCompare(right.name, "zh-Hans-CN"));
1726
+ }
1727
+ function normalizeCommandName(rawName) {
1728
+ const trimmed = rawName.trim();
1729
+ if (!trimmed) {
1730
+ return "";
1731
+ }
1732
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
1733
+ }
1734
+ function isAskUserQuestionTitle(title) {
1735
+ if (!title)
1736
+ return false;
1737
+ const lower = title.toLowerCase();
1738
+ return lower === "askuserquestion" || lower.startsWith("askuserquestion ");
1739
+ }
570
1740
  export const sessionManagerTestables = {
1741
+ stringifyMaybe,
1742
+ formatToolDetails,
1743
+ summarizeToolTitle,
1744
+ normalizeAcpToolTitle,
1745
+ isAskUserQuestionTitle,
1746
+ asRecord,
1747
+ extractChunkText,
1748
+ normalizeAvailableCommandsSnapshot,
1749
+ formatEditToolChangeMessage,
1750
+ labelForMode,
571
1751
  formatError,
572
1752
  };