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.
- package/README.md +11 -2
- package/dist/server/__tests__/acp-session.test.js +115 -0
- package/dist/server/__tests__/activity-monitor.test.js +13 -1
- package/dist/server/__tests__/session-manager.test.js +380 -1
- package/dist/server/acp-session.js +476 -0
- package/dist/server/activity-monitor.js +22 -7
- package/dist/server/index.js +57 -1
- package/dist/server/session-manager.js +1301 -121
- package/dist/web/assets/index-Bll9nc_X.js +21 -0
- package/dist/web/assets/index-y1qgSOLv.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -1
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/LICENSE +191 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/README.md +53 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +823 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js +965 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.d.ts +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js +839 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.d.ts +2 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js +225 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.d.ts +2 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js +130 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.d.ts +35 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js +5 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js +28 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts +2870 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js +3 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.d.ts +5333 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js +1554 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js +64 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/package.json +66 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/schema/schema.json +4125 -0
- package/vendor/claude-code-acp/node_modules/@types/node/LICENSE +21 -0
- package/vendor/claude-code-acp/node_modules/@types/node/README.md +15 -0
- package/vendor/claude-code-acp/node_modules/@types/node/assert/strict.d.ts +105 -0
- package/vendor/claude-code-acp/node_modules/@types/node/assert.d.ts +955 -0
- package/vendor/claude-code-acp/node_modules/@types/node/async_hooks.d.ts +623 -0
- package/vendor/claude-code-acp/node_modules/@types/node/buffer.buffer.d.ts +466 -0
- package/vendor/claude-code-acp/node_modules/@types/node/buffer.d.ts +1810 -0
- package/vendor/claude-code-acp/node_modules/@types/node/child_process.d.ts +1428 -0
- package/vendor/claude-code-acp/node_modules/@types/node/cluster.d.ts +486 -0
- package/vendor/claude-code-acp/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
- package/vendor/claude-code-acp/node_modules/@types/node/console.d.ts +151 -0
- package/vendor/claude-code-acp/node_modules/@types/node/constants.d.ts +20 -0
- package/vendor/claude-code-acp/node_modules/@types/node/crypto.d.ts +4065 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dgram.d.ts +564 -0
- package/vendor/claude-code-acp/node_modules/@types/node/diagnostics_channel.d.ts +576 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dns/promises.d.ts +503 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dns.d.ts +922 -0
- package/vendor/claude-code-acp/node_modules/@types/node/domain.d.ts +166 -0
- package/vendor/claude-code-acp/node_modules/@types/node/events.d.ts +1054 -0
- package/vendor/claude-code-acp/node_modules/@types/node/fs/promises.d.ts +1329 -0
- package/vendor/claude-code-acp/node_modules/@types/node/fs.d.ts +4676 -0
- package/vendor/claude-code-acp/node_modules/@types/node/globals.d.ts +150 -0
- package/vendor/claude-code-acp/node_modules/@types/node/globals.typedarray.d.ts +101 -0
- package/vendor/claude-code-acp/node_modules/@types/node/http.d.ts +2167 -0
- package/vendor/claude-code-acp/node_modules/@types/node/http2.d.ts +2480 -0
- package/vendor/claude-code-acp/node_modules/@types/node/https.d.ts +405 -0
- package/vendor/claude-code-acp/node_modules/@types/node/index.d.ts +115 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector/promises.d.ts +41 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector.d.ts +224 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector.generated.d.ts +4226 -0
- package/vendor/claude-code-acp/node_modules/@types/node/module.d.ts +819 -0
- package/vendor/claude-code-acp/node_modules/@types/node/net.d.ts +933 -0
- package/vendor/claude-code-acp/node_modules/@types/node/os.d.ts +507 -0
- package/vendor/claude-code-acp/node_modules/@types/node/package.json +155 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path/posix.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path/win32.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path.d.ts +187 -0
- package/vendor/claude-code-acp/node_modules/@types/node/perf_hooks.d.ts +643 -0
- package/vendor/claude-code-acp/node_modules/@types/node/process.d.ts +2161 -0
- package/vendor/claude-code-acp/node_modules/@types/node/punycode.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/querystring.d.ts +152 -0
- package/vendor/claude-code-acp/node_modules/@types/node/quic.d.ts +910 -0
- package/vendor/claude-code-acp/node_modules/@types/node/readline/promises.d.ts +161 -0
- package/vendor/claude-code-acp/node_modules/@types/node/readline.d.ts +541 -0
- package/vendor/claude-code-acp/node_modules/@types/node/repl.d.ts +415 -0
- package/vendor/claude-code-acp/node_modules/@types/node/sea.d.ts +162 -0
- package/vendor/claude-code-acp/node_modules/@types/node/sqlite.d.ts +955 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/consumers.d.ts +38 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/promises.d.ts +211 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/web.d.ts +296 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream.d.ts +1760 -0
- package/vendor/claude-code-acp/node_modules/@types/node/string_decoder.d.ts +67 -0
- package/vendor/claude-code-acp/node_modules/@types/node/test/reporters.d.ts +96 -0
- package/vendor/claude-code-acp/node_modules/@types/node/test.d.ts +2240 -0
- package/vendor/claude-code-acp/node_modules/@types/node/timers/promises.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/@types/node/timers.d.ts +159 -0
- package/vendor/claude-code-acp/node_modules/@types/node/tls.d.ts +1198 -0
- package/vendor/claude-code-acp/node_modules/@types/node/trace_events.d.ts +197 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +462 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +71 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +36 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/index.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +72 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/index.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/tty.d.ts +250 -0
- package/vendor/claude-code-acp/node_modules/@types/node/url.d.ts +519 -0
- package/vendor/claude-code-acp/node_modules/@types/node/util/types.d.ts +558 -0
- package/vendor/claude-code-acp/node_modules/@types/node/util.d.ts +1662 -0
- package/vendor/claude-code-acp/node_modules/@types/node/v8.d.ts +983 -0
- package/vendor/claude-code-acp/node_modules/@types/node/vm.d.ts +1208 -0
- package/vendor/claude-code-acp/node_modules/@types/node/wasi.d.ts +202 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/abortcontroller.d.ts +59 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/blob.d.ts +23 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/console.d.ts +9 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/crypto.d.ts +39 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/encoding.d.ts +11 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/events.d.ts +106 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/fetch.d.ts +69 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/importmeta.d.ts +13 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/messaging.d.ts +23 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/navigator.d.ts +25 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/performance.d.ts +45 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/storage.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/streams.d.ts +115 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/timers.d.ts +44 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/url.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@types/node/worker_threads.d.ts +717 -0
- package/vendor/claude-code-acp/node_modules/@types/node/zlib.d.ts +618 -0
- package/vendor/claude-code-acp/node_modules/undici-types/LICENSE +21 -0
- package/vendor/claude-code-acp/node_modules/undici-types/README.md +6 -0
- package/vendor/claude-code-acp/node_modules/undici-types/agent.d.ts +32 -0
- package/vendor/claude-code-acp/node_modules/undici-types/api.d.ts +43 -0
- package/vendor/claude-code-acp/node_modules/undici-types/balanced-pool.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cache-interceptor.d.ts +172 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cache.d.ts +36 -0
- package/vendor/claude-code-acp/node_modules/undici-types/client-stats.d.ts +15 -0
- package/vendor/claude-code-acp/node_modules/undici-types/client.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/undici-types/connector.d.ts +34 -0
- package/vendor/claude-code-acp/node_modules/undici-types/content-type.d.ts +21 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cookies.d.ts +30 -0
- package/vendor/claude-code-acp/node_modules/undici-types/diagnostics-channel.d.ts +74 -0
- package/vendor/claude-code-acp/node_modules/undici-types/dispatcher.d.ts +276 -0
- package/vendor/claude-code-acp/node_modules/undici-types/env-http-proxy-agent.d.ts +22 -0
- package/vendor/claude-code-acp/node_modules/undici-types/errors.d.ts +161 -0
- package/vendor/claude-code-acp/node_modules/undici-types/eventsource.d.ts +66 -0
- package/vendor/claude-code-acp/node_modules/undici-types/fetch.d.ts +211 -0
- package/vendor/claude-code-acp/node_modules/undici-types/formdata.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/undici-types/global-dispatcher.d.ts +9 -0
- package/vendor/claude-code-acp/node_modules/undici-types/global-origin.d.ts +7 -0
- package/vendor/claude-code-acp/node_modules/undici-types/h2c-client.d.ts +73 -0
- package/vendor/claude-code-acp/node_modules/undici-types/handlers.d.ts +15 -0
- package/vendor/claude-code-acp/node_modules/undici-types/header.d.ts +160 -0
- package/vendor/claude-code-acp/node_modules/undici-types/index.d.ts +80 -0
- package/vendor/claude-code-acp/node_modules/undici-types/interceptors.d.ts +39 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-agent.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-call-history.d.ts +111 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-client.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-errors.d.ts +12 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-interceptor.d.ts +94 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-pool.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/undici-types/package.json +55 -0
- package/vendor/claude-code-acp/node_modules/undici-types/patch.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/pool-stats.d.ts +19 -0
- package/vendor/claude-code-acp/node_modules/undici-types/pool.d.ts +41 -0
- package/vendor/claude-code-acp/node_modules/undici-types/proxy-agent.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/readable.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/undici-types/retry-agent.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/undici-types/retry-handler.d.ts +125 -0
- package/vendor/claude-code-acp/node_modules/undici-types/snapshot-agent.d.ts +109 -0
- package/vendor/claude-code-acp/node_modules/undici-types/util.d.ts +18 -0
- package/vendor/claude-code-acp/node_modules/undici-types/utility.d.ts +7 -0
- package/vendor/claude-code-acp/node_modules/undici-types/webidl.d.ts +341 -0
- package/vendor/claude-code-acp/node_modules/undici-types/websocket.d.ts +186 -0
- package/dist/web/assets/index-B5Dh2E8j.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
const entry = {
|
|
77
84
|
snapshot,
|
|
78
85
|
cliSession: null,
|
|
86
|
+
cliExitExpected: false,
|
|
87
|
+
acpSession: null,
|
|
88
|
+
acpFullTimeline: [],
|
|
79
89
|
outputBuffer: "",
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
|
352
|
-
const sid =
|
|
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
|
|
357
|
-
candidates.push({ name:
|
|
1286
|
+
const fileStat = await stat(path.join(dirPath, file));
|
|
1287
|
+
candidates.push({ name: file, mtimeMs: fileStat.mtimeMs });
|
|
358
1288
|
}
|
|
359
1289
|
catch {
|
|
360
|
-
//
|
|
1290
|
+
// ignore unreadable candidate
|
|
361
1291
|
}
|
|
362
1292
|
}
|
|
363
|
-
candidates.sort((
|
|
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
|
|
371
|
-
const { bytesRead } = await fd.read(
|
|
372
|
-
const head =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
445
|
-
this.lastHistorySize =
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
491
|
-
if (stats.size === this.lastHistorySize)
|
|
1395
|
+
if (historyStats.size === this.lastHistorySize)
|
|
492
1396
|
return;
|
|
493
|
-
const readSize =
|
|
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 =
|
|
1401
|
+
this.lastHistorySize = historyStats.size;
|
|
498
1402
|
const newText = buffer.toString("utf8");
|
|
499
|
-
const lines = newText.split("\n").filter((
|
|
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
|
-
//
|
|
1418
|
+
// ignore malformed line
|
|
517
1419
|
}
|
|
518
1420
|
}
|
|
519
1421
|
}
|
|
520
1422
|
catch {
|
|
521
|
-
//
|
|
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
|
};
|