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,300 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Readable, Writable } from "node:stream";
|
|
6
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
7
|
+
export class ClaudeAcpSession {
|
|
8
|
+
workspacePath;
|
|
9
|
+
agentBinPath;
|
|
10
|
+
onEvent;
|
|
11
|
+
pendingPermissions = new Map();
|
|
12
|
+
agentProcess = null;
|
|
13
|
+
connection = null;
|
|
14
|
+
sessionId = null;
|
|
15
|
+
activePrompt = false;
|
|
16
|
+
connectPromise = null;
|
|
17
|
+
sessionPromise = null;
|
|
18
|
+
currentModeId = null;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.workspacePath = options.workspacePath;
|
|
21
|
+
this.agentBinPath = options.agentBinPath;
|
|
22
|
+
this.onEvent = options.onEvent;
|
|
23
|
+
}
|
|
24
|
+
async connect() {
|
|
25
|
+
if (this.connectPromise) {
|
|
26
|
+
await this.connectPromise;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (this.connection) {
|
|
30
|
+
this.emitReady();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.connectPromise = (async () => {
|
|
34
|
+
await mkdir(this.workspacePath, { recursive: true });
|
|
35
|
+
this.agentProcess = spawn(this.agentBinPath, [], {
|
|
36
|
+
cwd: this.workspacePath,
|
|
37
|
+
env: process.env,
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
});
|
|
40
|
+
this.agentProcess.stderr.on("data", (chunk) => {
|
|
41
|
+
const message = chunk.toString().trim();
|
|
42
|
+
if (!message || this.shouldIgnoreAgentStderr(message) || this.shouldIgnoreToolOutputLog(message)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.onEvent({ type: "error", payload: { message } });
|
|
46
|
+
});
|
|
47
|
+
this.agentProcess.on("exit", (code, signal) => {
|
|
48
|
+
this.connection = null;
|
|
49
|
+
this.sessionId = null;
|
|
50
|
+
this.sessionPromise = null;
|
|
51
|
+
this.connectPromise = null;
|
|
52
|
+
this.activePrompt = false;
|
|
53
|
+
this.rejectPendingPermissions(new Error("Permission request cancelled because ACP agent exited."));
|
|
54
|
+
this.onEvent({
|
|
55
|
+
type: "error",
|
|
56
|
+
payload: { message: `Claude ACP agent exited (${code ?? "null"} / ${signal ?? "null"}).` },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const input = Writable.toWeb(this.agentProcess.stdin);
|
|
60
|
+
const output = Readable.toWeb(this.agentProcess.stdout);
|
|
61
|
+
const stream = acp.ndJsonStream(input, output);
|
|
62
|
+
const client = {
|
|
63
|
+
requestPermission: async (params) => this.handlePermissionRequest(params),
|
|
64
|
+
sessionUpdate: async (params) => {
|
|
65
|
+
this.onEvent({ type: "session_update", payload: params.update });
|
|
66
|
+
},
|
|
67
|
+
readTextFile: async (params) => {
|
|
68
|
+
const filePath = this.resolveWorkspacePath(params.path);
|
|
69
|
+
const content = await readFile(filePath, "utf8");
|
|
70
|
+
return { content };
|
|
71
|
+
},
|
|
72
|
+
writeTextFile: async (params) => {
|
|
73
|
+
const filePath = this.resolveWorkspacePath(params.path);
|
|
74
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
75
|
+
await writeFile(filePath, params.content, "utf8");
|
|
76
|
+
return {};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
this.connection = new acp.ClientSideConnection(() => client, stream);
|
|
80
|
+
await this.connection.initialize({
|
|
81
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
82
|
+
clientCapabilities: {
|
|
83
|
+
fs: {
|
|
84
|
+
readTextFile: true,
|
|
85
|
+
writeTextFile: true,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
this.emitReady();
|
|
90
|
+
})();
|
|
91
|
+
try {
|
|
92
|
+
await this.connectPromise;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.connectPromise = null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async ensureSession() {
|
|
100
|
+
if (this.sessionPromise) {
|
|
101
|
+
return await this.sessionPromise;
|
|
102
|
+
}
|
|
103
|
+
if (!this.connection) {
|
|
104
|
+
await this.connect();
|
|
105
|
+
}
|
|
106
|
+
if (this.sessionId || !this.connection) {
|
|
107
|
+
return this.sessionId;
|
|
108
|
+
}
|
|
109
|
+
this.sessionPromise = (async () => {
|
|
110
|
+
const response = await this.connection.newSession({
|
|
111
|
+
cwd: this.workspacePath,
|
|
112
|
+
mcpServers: [],
|
|
113
|
+
});
|
|
114
|
+
this.sessionId = response.sessionId;
|
|
115
|
+
this.currentModeId = null;
|
|
116
|
+
this.onEvent({
|
|
117
|
+
type: "session_created",
|
|
118
|
+
payload: {
|
|
119
|
+
sessionId: response.sessionId,
|
|
120
|
+
modes: response.modes?.availableModes.map((mode) => mode.id) ?? [],
|
|
121
|
+
configOptions: response.configOptions ?? [],
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return this.sessionId;
|
|
125
|
+
})();
|
|
126
|
+
try {
|
|
127
|
+
return await this.sessionPromise;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.sessionPromise = null;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async loadSession(existingSessionId) {
|
|
135
|
+
if (!this.connection) {
|
|
136
|
+
await this.connect();
|
|
137
|
+
}
|
|
138
|
+
if (!this.connection) {
|
|
139
|
+
throw new Error("ACP connection is not available.");
|
|
140
|
+
}
|
|
141
|
+
this.sessionId = existingSessionId;
|
|
142
|
+
this.sessionPromise = Promise.resolve(existingSessionId);
|
|
143
|
+
const response = await this.connection.loadSession({
|
|
144
|
+
sessionId: existingSessionId,
|
|
145
|
+
cwd: this.workspacePath,
|
|
146
|
+
mcpServers: [],
|
|
147
|
+
});
|
|
148
|
+
this.currentModeId = response.modes?.currentModeId ?? null;
|
|
149
|
+
this.onEvent({
|
|
150
|
+
type: "session_restored",
|
|
151
|
+
payload: {
|
|
152
|
+
sessionId: existingSessionId,
|
|
153
|
+
modes: response.modes?.availableModes.map((mode) => mode.id) ?? [],
|
|
154
|
+
configOptions: response.configOptions ?? [],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
return existingSessionId;
|
|
158
|
+
}
|
|
159
|
+
async findRestorableSession(preferredSessionId) {
|
|
160
|
+
if (!this.connection) {
|
|
161
|
+
await this.connect();
|
|
162
|
+
}
|
|
163
|
+
if (!this.connection) {
|
|
164
|
+
throw new Error("ACP connection is not available.");
|
|
165
|
+
}
|
|
166
|
+
const response = await this.connection.unstable_listSessions({
|
|
167
|
+
cwd: this.workspacePath,
|
|
168
|
+
});
|
|
169
|
+
if (preferredSessionId) {
|
|
170
|
+
const exactMatch = response.sessions.find((session) => session.sessionId === preferredSessionId);
|
|
171
|
+
if (exactMatch) {
|
|
172
|
+
return exactMatch.sessionId;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return response.sessions[0]?.sessionId ?? null;
|
|
176
|
+
}
|
|
177
|
+
async prompt(text, images) {
|
|
178
|
+
const sessionId = await this.ensureSession();
|
|
179
|
+
if (!this.connection || !sessionId) {
|
|
180
|
+
throw new Error("ACP session is not available.");
|
|
181
|
+
}
|
|
182
|
+
if (this.activePrompt) {
|
|
183
|
+
throw new Error("Another Claude prompt is still running.");
|
|
184
|
+
}
|
|
185
|
+
this.activePrompt = true;
|
|
186
|
+
const promptId = randomUUID();
|
|
187
|
+
this.onEvent({ type: "prompt_started", payload: { promptId, text } });
|
|
188
|
+
try {
|
|
189
|
+
// Images first, then text — mirrors the convention used by Claude's own clients
|
|
190
|
+
// (vision context before the instruction yields better results).
|
|
191
|
+
const promptContent = [];
|
|
192
|
+
if (images && images.length > 0) {
|
|
193
|
+
for (const img of images) {
|
|
194
|
+
promptContent.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
promptContent.push({ type: "text", text });
|
|
198
|
+
const response = await this.connection.prompt({
|
|
199
|
+
sessionId,
|
|
200
|
+
messageId: randomUUID(),
|
|
201
|
+
prompt: promptContent,
|
|
202
|
+
});
|
|
203
|
+
this.onEvent({
|
|
204
|
+
type: "prompt_finished",
|
|
205
|
+
payload: { promptId, stopReason: response.stopReason },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
this.activePrompt = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async setMode(modeId) {
|
|
213
|
+
const sessionId = await this.ensureSession();
|
|
214
|
+
if (!this.connection || !sessionId || !modeId || this.currentModeId === modeId) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await this.connection.setSessionMode({
|
|
218
|
+
sessionId,
|
|
219
|
+
modeId,
|
|
220
|
+
});
|
|
221
|
+
this.currentModeId = modeId;
|
|
222
|
+
}
|
|
223
|
+
async cancel() {
|
|
224
|
+
if (!this.connection || !this.sessionId || !this.activePrompt) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
await this.connection.cancel({ sessionId: this.sessionId });
|
|
228
|
+
}
|
|
229
|
+
async resolvePermission(requestId, optionId, note) {
|
|
230
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
231
|
+
if (!pending) {
|
|
232
|
+
throw new Error("Permission request was not found or already resolved.");
|
|
233
|
+
}
|
|
234
|
+
pending.resolve({
|
|
235
|
+
outcome: {
|
|
236
|
+
outcome: "selected",
|
|
237
|
+
optionId,
|
|
238
|
+
_meta: note && note.trim() ? { note: note.trim() } : undefined,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
this.pendingPermissions.delete(requestId);
|
|
242
|
+
this.onEvent({ type: "permission_resolved", payload: { requestId, optionId } });
|
|
243
|
+
}
|
|
244
|
+
async dispose() {
|
|
245
|
+
this.rejectPendingPermissions(new Error("Client disconnected."));
|
|
246
|
+
if (this.agentProcess && !this.agentProcess.killed) {
|
|
247
|
+
this.agentProcess.kill();
|
|
248
|
+
}
|
|
249
|
+
this.agentProcess = null;
|
|
250
|
+
this.connection = null;
|
|
251
|
+
this.sessionId = null;
|
|
252
|
+
this.sessionPromise = null;
|
|
253
|
+
this.connectPromise = null;
|
|
254
|
+
this.currentModeId = null;
|
|
255
|
+
this.activePrompt = false;
|
|
256
|
+
}
|
|
257
|
+
emitReady() {
|
|
258
|
+
this.onEvent({
|
|
259
|
+
type: "ready",
|
|
260
|
+
payload: { workspacePath: this.workspacePath, agentConnected: true },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
shouldIgnoreAgentStderr(message) {
|
|
264
|
+
return (message.includes("Error handling notification") &&
|
|
265
|
+
message.includes("method: 'session/update'") &&
|
|
266
|
+
message.includes("message: 'Invalid params'"));
|
|
267
|
+
}
|
|
268
|
+
shouldIgnoreToolOutputLog(message) {
|
|
269
|
+
const normalized = message.trim();
|
|
270
|
+
return normalized.startsWith('[{"index":') || normalized.startsWith("[{\"index\":");
|
|
271
|
+
}
|
|
272
|
+
async handlePermissionRequest(params) {
|
|
273
|
+
const requestId = randomUUID();
|
|
274
|
+
this.onEvent({
|
|
275
|
+
type: "permission_requested",
|
|
276
|
+
payload: {
|
|
277
|
+
requestId,
|
|
278
|
+
toolCall: params.toolCall,
|
|
279
|
+
options: params.options,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
return await new Promise((resolve, reject) => {
|
|
283
|
+
this.pendingPermissions.set(requestId, { resolve, reject });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
rejectPendingPermissions(reason) {
|
|
287
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
288
|
+
pending.reject(reason);
|
|
289
|
+
}
|
|
290
|
+
this.pendingPermissions.clear();
|
|
291
|
+
}
|
|
292
|
+
resolveWorkspacePath(targetPath) {
|
|
293
|
+
const absolutePath = path.resolve(this.workspacePath, targetPath);
|
|
294
|
+
const relativePath = path.relative(this.workspacePath, absolutePath);
|
|
295
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
296
|
+
throw new Error(`Refusing to access file outside workspace: ${targetPath}`);
|
|
297
|
+
}
|
|
298
|
+
return absolutePath;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const DIFF_TOO_LARGE_THRESHOLD_BYTES = Number(process.env.LEDUO_PATROL_MAX_DIFF_FILE_BYTES ?? 200 * 1024);
|
|
7
|
+
export async function buildWorkspaceDiffFilesSnapshot(workspacePath) {
|
|
8
|
+
const repositoryRoot = await resolveRepositoryRoot(workspacePath);
|
|
9
|
+
const statusOutput = await runGit(repositoryRoot, ["status", "--porcelain", "--", "."]);
|
|
10
|
+
const parsed = parsePorcelainStatus(statusOutput);
|
|
11
|
+
return {
|
|
12
|
+
workspacePath,
|
|
13
|
+
workspaceReadonly: await detectReadonly(workspacePath),
|
|
14
|
+
repositoryRoot,
|
|
15
|
+
workingTree: parsed.workingTree,
|
|
16
|
+
staged: parsed.staged,
|
|
17
|
+
untracked: parsed.untracked,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function buildSingleFileDiff(workspacePath, category, filePath) {
|
|
21
|
+
const repositoryRoot = await resolveRepositoryRoot(workspacePath);
|
|
22
|
+
const normalizedPath = filePath.trim();
|
|
23
|
+
if (!normalizedPath) {
|
|
24
|
+
throw new Error("filePath is required");
|
|
25
|
+
}
|
|
26
|
+
const diff = category === "workingTree"
|
|
27
|
+
? await runGit(repositoryRoot, ["diff", "--no-color", "--", normalizedPath])
|
|
28
|
+
: category === "staged"
|
|
29
|
+
? await runGit(repositoryRoot, ["diff", "--cached", "--no-color", "--", normalizedPath])
|
|
30
|
+
: await runDiffForUntrackedFile(repositoryRoot, normalizedPath);
|
|
31
|
+
if (Buffer.byteLength(diff, "utf8") > DIFF_TOO_LARGE_THRESHOLD_BYTES) {
|
|
32
|
+
return {
|
|
33
|
+
category,
|
|
34
|
+
filePath: normalizedPath,
|
|
35
|
+
omitted: true,
|
|
36
|
+
diff: "",
|
|
37
|
+
reason: `该文件 Diff 过大(>${Math.round(DIFF_TOO_LARGE_THRESHOLD_BYTES / 1024)}KB),已省略显示。`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
category,
|
|
42
|
+
filePath: normalizedPath,
|
|
43
|
+
omitted: false,
|
|
44
|
+
diff,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async function detectReadonly(workspacePath) {
|
|
48
|
+
try {
|
|
49
|
+
await access(workspacePath, constants.W_OK);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function resolveRepositoryRoot(workspacePath) {
|
|
57
|
+
const output = await runGit(workspacePath, ["rev-parse", "--show-toplevel"]);
|
|
58
|
+
const root = output.trim();
|
|
59
|
+
if (!root) {
|
|
60
|
+
throw new Error("当前目录不是 Git 仓库。");
|
|
61
|
+
}
|
|
62
|
+
return root;
|
|
63
|
+
}
|
|
64
|
+
async function runGit(cwd, args) {
|
|
65
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
66
|
+
cwd,
|
|
67
|
+
env: process.env,
|
|
68
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
69
|
+
});
|
|
70
|
+
return stdout;
|
|
71
|
+
}
|
|
72
|
+
async function runDiffForUntrackedFile(cwd, relativePath) {
|
|
73
|
+
const absolutePath = path.join(cwd, relativePath);
|
|
74
|
+
try {
|
|
75
|
+
return await runGit(cwd, ["diff", "--no-index", "--no-color", "/dev/null", absolutePath]);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (isExpectedNoIndexExit(error)) {
|
|
79
|
+
return error.stdout;
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function isExpectedNoIndexExit(error) {
|
|
85
|
+
return !!error && typeof error === "object" && "code" in error && error.code === 1 && "stdout" in error;
|
|
86
|
+
}
|
|
87
|
+
function parsePorcelainStatus(statusOutput) {
|
|
88
|
+
const workingTree = new Set();
|
|
89
|
+
const staged = new Set();
|
|
90
|
+
const untracked = new Set();
|
|
91
|
+
for (const rawLine of statusOutput.split("\n")) {
|
|
92
|
+
const line = rawLine.trimEnd();
|
|
93
|
+
if (!line) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const statusCode = line.slice(0, 2);
|
|
97
|
+
const pathText = normalizeStatusPath(line.slice(3).trim());
|
|
98
|
+
if (!pathText) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (statusCode === "??") {
|
|
102
|
+
untracked.add(pathText);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const stagedCode = statusCode[0] ?? " ";
|
|
106
|
+
const workingCode = statusCode[1] ?? " ";
|
|
107
|
+
if (stagedCode !== " ") {
|
|
108
|
+
staged.add(pathText);
|
|
109
|
+
}
|
|
110
|
+
if (workingCode !== " ") {
|
|
111
|
+
workingTree.add(pathText);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
workingTree: [...workingTree].sort((a, b) => a.localeCompare(b)).map((filePath) => ({ filePath, changeType: "修改" })),
|
|
116
|
+
staged: [...staged].sort((a, b) => a.localeCompare(b)).map((filePath) => ({ filePath, changeType: "修改" })),
|
|
117
|
+
untracked: [...untracked].sort((a, b) => a.localeCompare(b)).map((filePath) => ({ filePath, changeType: "新增" })),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function normalizeStatusPath(pathText) {
|
|
121
|
+
const renameMarker = " -> ";
|
|
122
|
+
const renamedTo = pathText.includes(renameMarker) ? pathText.split(renameMarker).at(-1) : pathText;
|
|
123
|
+
return (renamedTo ?? "").trim();
|
|
124
|
+
}
|