leduo-patrol 1.0.0
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 +217 -0
- package/dist/server/__tests__/access-key.test.js +25 -0
- package/dist/server/__tests__/acp-session.test.js +54 -0
- package/dist/server/__tests__/network.test.js +28 -0
- package/dist/server/__tests__/server-helpers.test.js +18 -0
- package/dist/server/__tests__/session-manager.test.js +152 -0
- package/dist/server/access-key.js +40 -0
- package/dist/server/acp-session.js +300 -0
- package/dist/server/git-diff.js +124 -0
- package/dist/server/index.js +313 -0
- package/dist/server/network.js +62 -0
- package/dist/server/server-helpers.js +23 -0
- package/dist/server/session-manager.js +778 -0
- package/dist/server/shell-session.js +84 -0
- package/dist/web/assets/addon-fit-DX4qG4td.js +1 -0
- package/dist/web/assets/brand-icon.png +0 -0
- package/dist/web/assets/index-BbPJ87hi.js +33 -0
- package/dist/web/assets/index-yhylkmhc.css +1 -0
- package/dist/web/assets/xterm-B-qIQCd3.js +16 -0
- package/dist/web/index.html +14 -0
- package/package.json +53 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile, access } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { ClaudeAcpSession } from "./acp-session.js";
|
|
6
|
+
export class SessionManager {
|
|
7
|
+
static INITIAL_TIMELINE_WINDOW = 120;
|
|
8
|
+
static HISTORY_PAGE_SIZE = 120;
|
|
9
|
+
allowedRoots;
|
|
10
|
+
agentBinPath;
|
|
11
|
+
stateFilePath;
|
|
12
|
+
sessions = new Map();
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
persistTimer = null;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.allowedRoots = options.allowedRoots;
|
|
17
|
+
this.agentBinPath = options.agentBinPath;
|
|
18
|
+
this.stateFilePath = path.join(os.homedir(), ".leduo-patrol", "state.json");
|
|
19
|
+
}
|
|
20
|
+
async initialize() {
|
|
21
|
+
const persistedState = await this.readPersistedState();
|
|
22
|
+
for (const snapshot of persistedState.sessions) {
|
|
23
|
+
const restoredSnapshot = {
|
|
24
|
+
...snapshot,
|
|
25
|
+
defaultModeId: snapshot.defaultModeId ?? "default",
|
|
26
|
+
currentModeId: snapshot.currentModeId ?? snapshot.defaultModeId ?? "default",
|
|
27
|
+
connectionState: "connecting",
|
|
28
|
+
modes: [],
|
|
29
|
+
busy: false,
|
|
30
|
+
timeline: [],
|
|
31
|
+
historyTotal: 0,
|
|
32
|
+
historyStart: 0,
|
|
33
|
+
permissions: [],
|
|
34
|
+
availableCommands: normalizeAvailableCommandsSnapshot(snapshot.availableCommands),
|
|
35
|
+
};
|
|
36
|
+
this.sessions.set(restoredSnapshot.clientSessionId, {
|
|
37
|
+
snapshot: restoredSnapshot,
|
|
38
|
+
acpSession: null,
|
|
39
|
+
connectPromise: null,
|
|
40
|
+
fullTimeline: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
for (const entry of this.sessions.values()) {
|
|
44
|
+
this.connectSession(entry).catch((error) => {
|
|
45
|
+
this.handleManagerError(entry.snapshot.clientSessionId, error);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
subscribe(listener) {
|
|
50
|
+
this.listeners.add(listener);
|
|
51
|
+
return () => {
|
|
52
|
+
this.listeners.delete(listener);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
getStateSnapshot() {
|
|
56
|
+
return {
|
|
57
|
+
sessions: [...this.sessions.values()]
|
|
58
|
+
.map((entry) => entry.snapshot)
|
|
59
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
getSessionHistory(clientSessionId, before, limit = SessionManager.HISTORY_PAGE_SIZE) {
|
|
63
|
+
const entry = this.getEntry(clientSessionId);
|
|
64
|
+
const fullTimeline = this.ensureFullTimeline(entry);
|
|
65
|
+
const normalizedLimit = Number.isFinite(limit) ? limit : SessionManager.HISTORY_PAGE_SIZE;
|
|
66
|
+
const normalizedBefore = Number.isFinite(before) ? before : fullTimeline.length;
|
|
67
|
+
const safeLimit = Math.max(1, Math.min(normalizedLimit, SessionManager.HISTORY_PAGE_SIZE));
|
|
68
|
+
const safeBefore = Math.max(0, Math.min(normalizedBefore, fullTimeline.length));
|
|
69
|
+
const start = Math.max(0, safeBefore - safeLimit);
|
|
70
|
+
return {
|
|
71
|
+
items: fullTimeline.slice(start, safeBefore),
|
|
72
|
+
start,
|
|
73
|
+
total: fullTimeline.length,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
getSessionWorkspacePath(clientSessionId) {
|
|
77
|
+
return this.getEntry(clientSessionId).snapshot.workspacePath;
|
|
78
|
+
}
|
|
79
|
+
async createSession(requestedWorkspacePath, requestedTitle, modeId) {
|
|
80
|
+
const resolvedWorkspacePath = await this.resolveRequestedWorkspace(requestedWorkspacePath);
|
|
81
|
+
const existingEntry = [...this.sessions.values()].find((entry) => entry.snapshot.workspacePath === resolvedWorkspacePath);
|
|
82
|
+
if (existingEntry) {
|
|
83
|
+
this.emit({
|
|
84
|
+
type: "session_registered",
|
|
85
|
+
payload: {
|
|
86
|
+
clientSessionId: existingEntry.snapshot.clientSessionId,
|
|
87
|
+
title: existingEntry.snapshot.title,
|
|
88
|
+
workspacePath: existingEntry.snapshot.workspacePath,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return existingEntry.snapshot;
|
|
92
|
+
}
|
|
93
|
+
const snapshot = {
|
|
94
|
+
clientSessionId: randomUUID(),
|
|
95
|
+
title: requestedTitle?.trim() || path.basename(resolvedWorkspacePath) || resolvedWorkspacePath,
|
|
96
|
+
workspacePath: resolvedWorkspacePath,
|
|
97
|
+
connectionState: "connecting",
|
|
98
|
+
sessionId: "",
|
|
99
|
+
modes: [],
|
|
100
|
+
defaultModeId: modeId ?? "default",
|
|
101
|
+
currentModeId: modeId ?? "default",
|
|
102
|
+
busy: false,
|
|
103
|
+
timeline: [],
|
|
104
|
+
historyTotal: 0,
|
|
105
|
+
historyStart: 0,
|
|
106
|
+
permissions: [],
|
|
107
|
+
availableCommands: [],
|
|
108
|
+
updatedAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
const entry = {
|
|
111
|
+
snapshot,
|
|
112
|
+
acpSession: null,
|
|
113
|
+
connectPromise: null,
|
|
114
|
+
fullTimeline: [],
|
|
115
|
+
};
|
|
116
|
+
this.sessions.set(snapshot.clientSessionId, entry);
|
|
117
|
+
this.schedulePersist();
|
|
118
|
+
this.emit({
|
|
119
|
+
type: "session_registered",
|
|
120
|
+
payload: {
|
|
121
|
+
clientSessionId: snapshot.clientSessionId,
|
|
122
|
+
title: snapshot.title,
|
|
123
|
+
workspacePath: snapshot.workspacePath,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
await this.connectSession(entry);
|
|
127
|
+
return snapshot;
|
|
128
|
+
}
|
|
129
|
+
async prompt(clientSessionId, text, modeId, images) {
|
|
130
|
+
const entry = this.getEntry(clientSessionId);
|
|
131
|
+
await this.connectSession(entry);
|
|
132
|
+
const effectiveModeId = modeId || entry.snapshot.defaultModeId;
|
|
133
|
+
if (effectiveModeId) {
|
|
134
|
+
await entry.acpSession?.setMode(effectiveModeId);
|
|
135
|
+
entry.snapshot.currentModeId = effectiveModeId;
|
|
136
|
+
}
|
|
137
|
+
entry.snapshot.updatedAt = new Date().toISOString();
|
|
138
|
+
this.schedulePersist();
|
|
139
|
+
await entry.acpSession?.prompt(enrichPromptWithToolHints(text), images);
|
|
140
|
+
}
|
|
141
|
+
async setSessionMode(clientSessionId, modeId) {
|
|
142
|
+
const entry = this.getEntry(clientSessionId);
|
|
143
|
+
await this.connectSession(entry);
|
|
144
|
+
if (!modeId) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await entry.acpSession?.setMode(modeId);
|
|
148
|
+
entry.snapshot.defaultModeId = modeId;
|
|
149
|
+
entry.snapshot.currentModeId = modeId;
|
|
150
|
+
entry.snapshot.updatedAt = new Date().toISOString();
|
|
151
|
+
this.schedulePersist();
|
|
152
|
+
this.emit({
|
|
153
|
+
type: "session_mode_changed",
|
|
154
|
+
payload: {
|
|
155
|
+
clientSessionId,
|
|
156
|
+
defaultModeId: entry.snapshot.defaultModeId,
|
|
157
|
+
currentModeId: entry.snapshot.currentModeId,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async cancel(clientSessionId) {
|
|
162
|
+
await this.getEntry(clientSessionId).acpSession?.cancel();
|
|
163
|
+
}
|
|
164
|
+
async resolvePermission(clientSessionId, requestId, optionId, note) {
|
|
165
|
+
await this.getEntry(clientSessionId).acpSession?.resolvePermission(requestId, optionId, note);
|
|
166
|
+
}
|
|
167
|
+
async closeSession(clientSessionId) {
|
|
168
|
+
const entry = this.sessions.get(clientSessionId);
|
|
169
|
+
if (!entry) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await entry.acpSession?.dispose();
|
|
173
|
+
this.sessions.delete(clientSessionId);
|
|
174
|
+
this.schedulePersist();
|
|
175
|
+
this.emit({
|
|
176
|
+
type: "session_closed",
|
|
177
|
+
payload: { clientSessionId },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async connectSession(entry) {
|
|
181
|
+
if (entry.connectPromise) {
|
|
182
|
+
await entry.connectPromise;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (entry.acpSession) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
entry.snapshot.connectionState = "connecting";
|
|
189
|
+
entry.snapshot.updatedAt = new Date().toISOString();
|
|
190
|
+
this.schedulePersist();
|
|
191
|
+
entry.connectPromise = (async () => {
|
|
192
|
+
const acpSession = new ClaudeAcpSession({
|
|
193
|
+
workspacePath: entry.snapshot.workspacePath,
|
|
194
|
+
agentBinPath: this.agentBinPath,
|
|
195
|
+
onEvent: (event) => this.handleSessionEvent(entry.snapshot.clientSessionId, event),
|
|
196
|
+
});
|
|
197
|
+
entry.acpSession = acpSession;
|
|
198
|
+
await acpSession.connect();
|
|
199
|
+
if (entry.snapshot.sessionId) {
|
|
200
|
+
entry.fullTimeline = [];
|
|
201
|
+
this.syncVisibleTimeline(entry);
|
|
202
|
+
const restorableSessionId = await acpSession.findRestorableSession(entry.snapshot.sessionId);
|
|
203
|
+
if (restorableSessionId) {
|
|
204
|
+
entry.snapshot.sessionId = restorableSessionId;
|
|
205
|
+
await acpSession.loadSession(restorableSessionId);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
throw new Error(`No Claude session found for ${entry.snapshot.workspacePath}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
await acpSession.ensureSession();
|
|
213
|
+
}
|
|
214
|
+
if (entry.snapshot.defaultModeId && entry.snapshot.currentModeId !== entry.snapshot.defaultModeId) {
|
|
215
|
+
await acpSession.setMode(entry.snapshot.defaultModeId);
|
|
216
|
+
entry.snapshot.currentModeId = entry.snapshot.defaultModeId;
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
try {
|
|
220
|
+
await entry.connectPromise;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
entry.acpSession = null;
|
|
224
|
+
entry.snapshot.connectionState = "error";
|
|
225
|
+
this.handleManagerError(entry.snapshot.clientSessionId, error);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
entry.connectPromise = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
handleSessionEvent(clientSessionId, event) {
|
|
232
|
+
const entry = this.sessions.get(clientSessionId);
|
|
233
|
+
if (!entry) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
switch (event.type) {
|
|
237
|
+
case "ready":
|
|
238
|
+
entry.snapshot.connectionState = "connected";
|
|
239
|
+
entry.snapshot.workspacePath = event.payload.workspacePath;
|
|
240
|
+
this.appendTimeline(entry, {
|
|
241
|
+
id: randomUUID(),
|
|
242
|
+
kind: "system",
|
|
243
|
+
title: "Claude ACP 已连接",
|
|
244
|
+
body: event.payload.workspacePath,
|
|
245
|
+
});
|
|
246
|
+
break;
|
|
247
|
+
case "session_created":
|
|
248
|
+
entry.snapshot.sessionId = event.payload.sessionId;
|
|
249
|
+
entry.snapshot.modes = event.payload.modes;
|
|
250
|
+
entry.snapshot.currentModeId = entry.snapshot.currentModeId || entry.snapshot.defaultModeId || "default";
|
|
251
|
+
entry.snapshot.connectionState = "connected";
|
|
252
|
+
this.appendTimeline(entry, {
|
|
253
|
+
id: randomUUID(),
|
|
254
|
+
kind: "system",
|
|
255
|
+
title: "会话已创建",
|
|
256
|
+
body: event.payload.sessionId,
|
|
257
|
+
meta: labelForMode(entry.snapshot.currentModeId || entry.snapshot.defaultModeId),
|
|
258
|
+
});
|
|
259
|
+
break;
|
|
260
|
+
case "session_restored":
|
|
261
|
+
entry.snapshot.sessionId = event.payload.sessionId;
|
|
262
|
+
entry.snapshot.modes = event.payload.modes;
|
|
263
|
+
entry.snapshot.connectionState = "connected";
|
|
264
|
+
this.appendTimeline(entry, {
|
|
265
|
+
id: randomUUID(),
|
|
266
|
+
kind: "system",
|
|
267
|
+
title: "会话已恢复",
|
|
268
|
+
body: event.payload.sessionId,
|
|
269
|
+
meta: labelForMode(entry.snapshot.currentModeId || entry.snapshot.defaultModeId),
|
|
270
|
+
});
|
|
271
|
+
break;
|
|
272
|
+
case "prompt_started":
|
|
273
|
+
entry.snapshot.busy = true;
|
|
274
|
+
this.appendTimeline(entry, {
|
|
275
|
+
id: event.payload.promptId,
|
|
276
|
+
kind: "user",
|
|
277
|
+
title: "你",
|
|
278
|
+
body: event.payload.text,
|
|
279
|
+
});
|
|
280
|
+
break;
|
|
281
|
+
case "prompt_finished":
|
|
282
|
+
entry.snapshot.busy = false;
|
|
283
|
+
this.appendTimeline(entry, {
|
|
284
|
+
id: randomUUID(),
|
|
285
|
+
kind: "system",
|
|
286
|
+
title: "本轮完成",
|
|
287
|
+
body: event.payload.stopReason,
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
case "session_update":
|
|
291
|
+
this.consumeSessionUpdate(entry, event.payload);
|
|
292
|
+
break;
|
|
293
|
+
case "permission_requested": {
|
|
294
|
+
const permission = {
|
|
295
|
+
clientSessionId,
|
|
296
|
+
requestId: event.payload.requestId,
|
|
297
|
+
toolCall: {
|
|
298
|
+
toolCallId: event.payload.toolCall.toolCallId,
|
|
299
|
+
title: event.payload.toolCall.title ?? undefined,
|
|
300
|
+
status: event.payload.toolCall.status ?? undefined,
|
|
301
|
+
rawInput: event.payload.toolCall.rawInput,
|
|
302
|
+
},
|
|
303
|
+
options: event.payload.options.map((option) => ({
|
|
304
|
+
optionId: option.optionId,
|
|
305
|
+
name: option.name,
|
|
306
|
+
kind: option.kind,
|
|
307
|
+
})),
|
|
308
|
+
};
|
|
309
|
+
entry.snapshot.permissions.push(permission);
|
|
310
|
+
this.appendTimeline(entry, {
|
|
311
|
+
id: event.payload.requestId,
|
|
312
|
+
kind: "tool",
|
|
313
|
+
title: summarizeToolTitle(event.payload.toolCall.title, event.payload.toolCall.rawInput, event.payload.toolCall.toolCallId),
|
|
314
|
+
body: formatToolDetails({
|
|
315
|
+
toolCallId: event.payload.toolCall.toolCallId,
|
|
316
|
+
title: event.payload.toolCall.title,
|
|
317
|
+
status: event.payload.toolCall.status,
|
|
318
|
+
rawInput: event.payload.toolCall.rawInput,
|
|
319
|
+
}),
|
|
320
|
+
meta: event.payload.toolCall.status ?? "pending",
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "permission_resolved":
|
|
325
|
+
entry.snapshot.permissions = entry.snapshot.permissions.filter((permission) => permission.requestId !== event.payload.requestId);
|
|
326
|
+
break;
|
|
327
|
+
case "error":
|
|
328
|
+
entry.snapshot.busy = false;
|
|
329
|
+
{
|
|
330
|
+
const editChangeMessage = formatEditToolChangeMessage(event.payload.message);
|
|
331
|
+
if (editChangeMessage) {
|
|
332
|
+
this.appendTimeline(entry, {
|
|
333
|
+
id: randomUUID(),
|
|
334
|
+
kind: "tool",
|
|
335
|
+
title: editChangeMessage.title,
|
|
336
|
+
body: editChangeMessage.body,
|
|
337
|
+
meta: "completed",
|
|
338
|
+
});
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
entry.snapshot.connectionState = "error";
|
|
343
|
+
this.appendTimeline(entry, {
|
|
344
|
+
id: randomUUID(),
|
|
345
|
+
kind: "error",
|
|
346
|
+
title: "错误",
|
|
347
|
+
body: event.payload.message,
|
|
348
|
+
});
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
entry.snapshot.updatedAt = new Date().toISOString();
|
|
352
|
+
this.schedulePersist();
|
|
353
|
+
this.emit({
|
|
354
|
+
...event,
|
|
355
|
+
payload: {
|
|
356
|
+
clientSessionId,
|
|
357
|
+
...event.payload,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
consumeSessionUpdate(entry, update) {
|
|
362
|
+
const snapshot = entry.snapshot;
|
|
363
|
+
switch (update.sessionUpdate) {
|
|
364
|
+
case "available_commands_update":
|
|
365
|
+
snapshot.availableCommands = normalizeAvailableCommandsSnapshot(update.availableCommands ?? update.supportedCommands ?? update.commands);
|
|
366
|
+
break;
|
|
367
|
+
case "agent_message_chunk": {
|
|
368
|
+
const chunkText = extractChunkText(update.content);
|
|
369
|
+
if (chunkText) {
|
|
370
|
+
this.appendTextChunk(entry, "agent", "Claude", chunkText);
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case "agent_thought_chunk": {
|
|
375
|
+
const chunkText = extractChunkText(update.content);
|
|
376
|
+
if (chunkText) {
|
|
377
|
+
this.appendTextChunk(entry, "thought", "思路", chunkText);
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "tool_call":
|
|
382
|
+
case "tool_call_update":
|
|
383
|
+
this.appendTimeline(entry, {
|
|
384
|
+
id: randomUUID(),
|
|
385
|
+
kind: "tool",
|
|
386
|
+
title: summarizeToolTitle(update.title, update.rawInput, update.toolCallId),
|
|
387
|
+
body: formatToolDetails({
|
|
388
|
+
toolCallId: update.toolCallId,
|
|
389
|
+
title: update.title,
|
|
390
|
+
status: update.status,
|
|
391
|
+
rawInput: update.rawInput,
|
|
392
|
+
rawOutput: update.rawOutput,
|
|
393
|
+
}),
|
|
394
|
+
meta: String(update.status ?? update.sessionUpdate),
|
|
395
|
+
});
|
|
396
|
+
break;
|
|
397
|
+
case "plan":
|
|
398
|
+
this.appendTimeline(entry, {
|
|
399
|
+
id: randomUUID(),
|
|
400
|
+
kind: "system",
|
|
401
|
+
title: "执行计划",
|
|
402
|
+
body: stringifyMaybe(update.entries ?? update),
|
|
403
|
+
});
|
|
404
|
+
break;
|
|
405
|
+
case "current_mode_update":
|
|
406
|
+
snapshot.currentModeId = String(update.currentModeId ?? snapshot.currentModeId ?? "default");
|
|
407
|
+
this.appendTimeline(entry, {
|
|
408
|
+
id: randomUUID(),
|
|
409
|
+
kind: "system",
|
|
410
|
+
title: "模式切换",
|
|
411
|
+
body: String(update.currentModeId ?? "unknown"),
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
default:
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
appendTextChunk(entry, kind, title, text) {
|
|
419
|
+
const fullTimeline = this.ensureFullTimeline(entry);
|
|
420
|
+
const lastItem = fullTimeline.at(-1);
|
|
421
|
+
if (lastItem && lastItem.kind === kind && lastItem.title === title && !lastItem.meta) {
|
|
422
|
+
lastItem.body += text;
|
|
423
|
+
this.syncVisibleTimeline(entry);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.appendTimeline(entry, {
|
|
427
|
+
id: randomUUID(),
|
|
428
|
+
kind,
|
|
429
|
+
title,
|
|
430
|
+
body: text,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
appendTimeline(entry, item) {
|
|
434
|
+
this.ensureFullTimeline(entry).push(item);
|
|
435
|
+
this.syncVisibleTimeline(entry);
|
|
436
|
+
}
|
|
437
|
+
syncVisibleTimeline(entry) {
|
|
438
|
+
const fullTimeline = this.ensureFullTimeline(entry);
|
|
439
|
+
const total = fullTimeline.length;
|
|
440
|
+
const start = Math.max(0, total - SessionManager.INITIAL_TIMELINE_WINDOW);
|
|
441
|
+
entry.snapshot.timeline = fullTimeline.slice(start);
|
|
442
|
+
entry.snapshot.historyTotal = total;
|
|
443
|
+
entry.snapshot.historyStart = start;
|
|
444
|
+
}
|
|
445
|
+
ensureFullTimeline(entry) {
|
|
446
|
+
if (!Array.isArray(entry.fullTimeline)) {
|
|
447
|
+
entry.fullTimeline = [];
|
|
448
|
+
}
|
|
449
|
+
return entry.fullTimeline;
|
|
450
|
+
}
|
|
451
|
+
emit(event) {
|
|
452
|
+
for (const listener of this.listeners) {
|
|
453
|
+
listener(event);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
schedulePersist() {
|
|
457
|
+
if (this.persistTimer) {
|
|
458
|
+
clearTimeout(this.persistTimer);
|
|
459
|
+
}
|
|
460
|
+
this.persistTimer = setTimeout(() => {
|
|
461
|
+
this.persistTimer = null;
|
|
462
|
+
this.writePersistedState().catch(() => undefined);
|
|
463
|
+
}, 200);
|
|
464
|
+
}
|
|
465
|
+
async writePersistedState() {
|
|
466
|
+
const payload = {
|
|
467
|
+
sessions: this.getStateSnapshot().sessions,
|
|
468
|
+
};
|
|
469
|
+
const persistedSessions = payload.sessions.map((session) => ({
|
|
470
|
+
clientSessionId: session.clientSessionId,
|
|
471
|
+
title: session.title,
|
|
472
|
+
workspacePath: session.workspacePath,
|
|
473
|
+
sessionId: session.sessionId,
|
|
474
|
+
defaultModeId: session.defaultModeId,
|
|
475
|
+
currentModeId: session.currentModeId,
|
|
476
|
+
availableCommands: normalizeAvailableCommandsSnapshot(session.availableCommands),
|
|
477
|
+
updatedAt: session.updatedAt,
|
|
478
|
+
}));
|
|
479
|
+
await mkdir(path.dirname(this.stateFilePath), { recursive: true });
|
|
480
|
+
await writeFile(this.stateFilePath, JSON.stringify({ sessions: persistedSessions }, null, 2), "utf8");
|
|
481
|
+
}
|
|
482
|
+
async readPersistedState() {
|
|
483
|
+
try {
|
|
484
|
+
const content = await readFile(this.stateFilePath, "utf8");
|
|
485
|
+
const parsed = JSON.parse(content);
|
|
486
|
+
if (!Array.isArray(parsed.sessions)) {
|
|
487
|
+
return { sessions: [] };
|
|
488
|
+
}
|
|
489
|
+
return parsed;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return { sessions: [] };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
getEntry(clientSessionId) {
|
|
496
|
+
const entry = this.sessions.get(clientSessionId);
|
|
497
|
+
if (!entry) {
|
|
498
|
+
throw new Error(`Session not found: ${clientSessionId}`);
|
|
499
|
+
}
|
|
500
|
+
return entry;
|
|
501
|
+
}
|
|
502
|
+
async resolveRequestedWorkspace(requestedWorkspacePath) {
|
|
503
|
+
const trimmedPath = requestedWorkspacePath.trim();
|
|
504
|
+
if (!trimmedPath) {
|
|
505
|
+
throw new Error("Workspace path is required.");
|
|
506
|
+
}
|
|
507
|
+
const resolvedWorkspacePath = path.resolve(trimmedPath);
|
|
508
|
+
const isAllowed = this.allowedRoots.some((rootPath) => {
|
|
509
|
+
const relativePath = path.relative(rootPath, resolvedWorkspacePath);
|
|
510
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
511
|
+
});
|
|
512
|
+
if (!isAllowed) {
|
|
513
|
+
throw new Error(`Workspace path is outside allowed roots: ${resolvedWorkspacePath}`);
|
|
514
|
+
}
|
|
515
|
+
await access(resolvedWorkspacePath);
|
|
516
|
+
return resolvedWorkspacePath;
|
|
517
|
+
}
|
|
518
|
+
handleManagerError(clientSessionId, error) {
|
|
519
|
+
const entry = this.sessions.get(clientSessionId);
|
|
520
|
+
if (!entry) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
entry.snapshot.busy = false;
|
|
524
|
+
entry.snapshot.connectionState = "error";
|
|
525
|
+
this.appendTimeline(entry, {
|
|
526
|
+
id: randomUUID(),
|
|
527
|
+
kind: "error",
|
|
528
|
+
title: "错误",
|
|
529
|
+
body: formatError(error),
|
|
530
|
+
});
|
|
531
|
+
entry.snapshot.updatedAt = new Date().toISOString();
|
|
532
|
+
this.schedulePersist();
|
|
533
|
+
this.emit({
|
|
534
|
+
type: "error",
|
|
535
|
+
payload: {
|
|
536
|
+
clientSessionId,
|
|
537
|
+
message: formatError(error),
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function stringifyMaybe(value) {
|
|
543
|
+
if (typeof value === "string") {
|
|
544
|
+
return value;
|
|
545
|
+
}
|
|
546
|
+
return JSON.stringify(value, null, 2);
|
|
547
|
+
}
|
|
548
|
+
function formatToolDetails(details) {
|
|
549
|
+
return stringifyMaybe({
|
|
550
|
+
toolCallId: details.toolCallId,
|
|
551
|
+
title: details.title,
|
|
552
|
+
status: details.status,
|
|
553
|
+
rawInput: details.rawInput,
|
|
554
|
+
rawOutput: details.rawOutput,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
function summarizeToolTitle(rawTitle, rawInput, rawToolCallId) {
|
|
558
|
+
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
|
|
559
|
+
const record = asRecord(rawInput) ?? asRecord(tryParseJson(rawInput));
|
|
560
|
+
const normalizedTitle = title.toLowerCase();
|
|
561
|
+
const isSubagent = normalizedTitle.includes("subagent") || normalizedTitle === "task" || normalizedTitle.includes(" task");
|
|
562
|
+
if (isSubagent) {
|
|
563
|
+
const summary = readSubagentSummary(record);
|
|
564
|
+
if (summary) {
|
|
565
|
+
return `${title || "Task"} · ${summary}`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (title && !/^工具\s+tool_/.test(title) && !/^tool_/.test(title)) {
|
|
569
|
+
return title;
|
|
570
|
+
}
|
|
571
|
+
const command = typeof record?.command === "string"
|
|
572
|
+
? record.command
|
|
573
|
+
: Array.isArray(record?.cmd)
|
|
574
|
+
? record.cmd.filter((part) => typeof part === "string").join(" ")
|
|
575
|
+
: null;
|
|
576
|
+
const pathValue = typeof record?.path === "string"
|
|
577
|
+
? record.path
|
|
578
|
+
: typeof record?.filePath === "string"
|
|
579
|
+
? record.filePath
|
|
580
|
+
: typeof record?.cwd === "string"
|
|
581
|
+
? record.cwd
|
|
582
|
+
: null;
|
|
583
|
+
const description = typeof record?.description === "string" ? record.description : null;
|
|
584
|
+
const args = Array.isArray(record?.args) ? record.args.filter((part) => typeof part === "string").join(" ") : null;
|
|
585
|
+
const summary = [command, pathValue, description, args].filter(Boolean).join(" · ");
|
|
586
|
+
if (summary) {
|
|
587
|
+
return summary;
|
|
588
|
+
}
|
|
589
|
+
if (title) {
|
|
590
|
+
return title;
|
|
591
|
+
}
|
|
592
|
+
return typeof rawToolCallId === "string" ? `工具 ${rawToolCallId}` : "工具调用";
|
|
593
|
+
}
|
|
594
|
+
function readSubagentSummary(record) {
|
|
595
|
+
if (!record) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
for (const key of ["title", "description"]) {
|
|
599
|
+
const value = record[key];
|
|
600
|
+
if (typeof value === "string" && value.trim()) {
|
|
601
|
+
return value.trim();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
for (const key of ["rawInput", "input", "args", "payload", "params"]) {
|
|
605
|
+
if (!(key in record)) {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const nestedRecord = asRecord(record[key]) ?? asRecord(tryParseJson(record[key]));
|
|
609
|
+
const nested = readSubagentSummary(nestedRecord);
|
|
610
|
+
if (nested) {
|
|
611
|
+
return nested;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
function tryParseJson(value) {
|
|
617
|
+
if (typeof value !== "string") {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const trimmed = value.trim();
|
|
621
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
return JSON.parse(trimmed);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function asRecord(value) {
|
|
632
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
633
|
+
}
|
|
634
|
+
function enrichPromptWithToolHints(text) {
|
|
635
|
+
const normalized = text.trim();
|
|
636
|
+
return normalized || text;
|
|
637
|
+
}
|
|
638
|
+
function formatEditToolChangeMessage(message) {
|
|
639
|
+
const parsed = tryParseJson(message);
|
|
640
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const files = parsed
|
|
644
|
+
.map((entry) => asRecord(entry))
|
|
645
|
+
.filter((entry) => Boolean(entry))
|
|
646
|
+
.map((entry) => {
|
|
647
|
+
const filePath = typeof entry.newFileName === "string"
|
|
648
|
+
? entry.newFileName
|
|
649
|
+
: typeof entry.oldFileName === "string"
|
|
650
|
+
? entry.oldFileName
|
|
651
|
+
: typeof entry.index === "string"
|
|
652
|
+
? entry.index
|
|
653
|
+
: "";
|
|
654
|
+
const hunks = Array.isArray(entry.hunks) ? entry.hunks.length : 0;
|
|
655
|
+
return { filePath, hunks };
|
|
656
|
+
})
|
|
657
|
+
.filter((entry) => entry.filePath);
|
|
658
|
+
if (files.length === 0) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
const lines = files.map((entry) => `- ${entry.filePath}${entry.hunks > 0 ? `(${entry.hunks} 处修改)` : ""}`);
|
|
662
|
+
return {
|
|
663
|
+
title: `Edit 已修改 ${files.length} 个文件`,
|
|
664
|
+
body: `Edit 工具已更新以下文件:\n${lines.join("\n")}`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function labelForMode(modeId) {
|
|
668
|
+
switch (modeId) {
|
|
669
|
+
case "default":
|
|
670
|
+
return "Default";
|
|
671
|
+
case "acceptEdits":
|
|
672
|
+
return "Accept Edits";
|
|
673
|
+
case "plan":
|
|
674
|
+
return "Plan";
|
|
675
|
+
case "dontAsk":
|
|
676
|
+
return "Don't Ask";
|
|
677
|
+
case "bypassPermissions":
|
|
678
|
+
return "Bypass Permissions";
|
|
679
|
+
default:
|
|
680
|
+
return modeId || "默认模式";
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function formatError(error) {
|
|
684
|
+
if (error instanceof Error) {
|
|
685
|
+
return error.message;
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
return JSON.stringify(error);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return String(error);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function extractChunkText(content) {
|
|
695
|
+
if (!content) {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
if (Array.isArray(content)) {
|
|
699
|
+
const joined = content.map((item) => extractChunkText(item)).filter((item) => Boolean(item)).join("\n");
|
|
700
|
+
return joined || null;
|
|
701
|
+
}
|
|
702
|
+
const record = asRecord(content);
|
|
703
|
+
if (!record) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
if (typeof record.text === "string" && record.text.trim()) {
|
|
707
|
+
return record.text;
|
|
708
|
+
}
|
|
709
|
+
if (record.type === "resource") {
|
|
710
|
+
const resource = asRecord(record.resource);
|
|
711
|
+
if (typeof resource?.text === "string" && resource.text.trim()) {
|
|
712
|
+
return resource.text;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (record.type === "resource_link") {
|
|
716
|
+
const uri = typeof record.uri === "string" ? record.uri : "";
|
|
717
|
+
return uri ? `[resource] ${uri}` : "[resource]";
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
function normalizeAvailableCommandsSnapshot(rawValue) {
|
|
722
|
+
if (!Array.isArray(rawValue)) {
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
const seen = new Set();
|
|
726
|
+
const normalized = [];
|
|
727
|
+
for (const item of rawValue) {
|
|
728
|
+
if (typeof item === "string") {
|
|
729
|
+
const name = normalizeCommandName(item);
|
|
730
|
+
if (!name || seen.has(name)) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
seen.add(name);
|
|
734
|
+
normalized.push({ name, description: "", inputType: "unstructured" });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const record = asRecord(item);
|
|
738
|
+
const rawName = typeof record?.name === "string"
|
|
739
|
+
? record.name
|
|
740
|
+
: typeof record?.command === "string"
|
|
741
|
+
? record.command
|
|
742
|
+
: "";
|
|
743
|
+
const name = normalizeCommandName(rawName);
|
|
744
|
+
if (!name || seen.has(name)) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
seen.add(name);
|
|
748
|
+
normalized.push({
|
|
749
|
+
name,
|
|
750
|
+
description: typeof record?.description === "string"
|
|
751
|
+
? record.description.trim()
|
|
752
|
+
: typeof record?.title === "string"
|
|
753
|
+
? record.title.trim()
|
|
754
|
+
: "",
|
|
755
|
+
inputType: "unstructured",
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return normalized.sort((left, right) => left.name.localeCompare(right.name, "zh-Hans-CN"));
|
|
759
|
+
}
|
|
760
|
+
function normalizeCommandName(rawName) {
|
|
761
|
+
const trimmed = rawName.trim();
|
|
762
|
+
if (!trimmed) {
|
|
763
|
+
return "";
|
|
764
|
+
}
|
|
765
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
766
|
+
}
|
|
767
|
+
export const sessionManagerTestables = {
|
|
768
|
+
stringifyMaybe,
|
|
769
|
+
formatToolDetails,
|
|
770
|
+
summarizeToolTitle,
|
|
771
|
+
asRecord,
|
|
772
|
+
extractChunkText,
|
|
773
|
+
normalizeAvailableCommandsSnapshot,
|
|
774
|
+
enrichPromptWithToolHints,
|
|
775
|
+
formatEditToolChangeMessage,
|
|
776
|
+
labelForMode,
|
|
777
|
+
formatError,
|
|
778
|
+
};
|