doer-agent 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-codex-auth-rpc.js +322 -0
- package/dist/agent-codex-cli.js +210 -0
- package/dist/agent-fs-rpc.js +402 -0
- package/dist/agent-git-rpc.js +299 -0
- package/dist/agent-jetstream.js +120 -0
- package/dist/agent-run-execution.js +39 -0
- package/dist/agent-run-lifecycle.js +67 -0
- package/dist/agent-run-rpc.js +93 -0
- package/dist/agent-run-state.js +229 -0
- package/dist/agent-runtime-env.js +147 -0
- package/dist/agent-runtime-io.js +112 -0
- package/dist/agent-runtime-utils.js +253 -0
- package/dist/agent-session-loop.js +53 -0
- package/dist/agent-session-rpc.js +867 -0
- package/dist/agent-settings-rpc.js +75 -0
- package/dist/agent-settings.js +397 -0
- package/dist/agent-skill-rpc.js +164 -0
- package/dist/agent-task-execution.js +275 -0
- package/dist/agent.js +376 -4275
- package/package.json +1 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { StringCodec } from "nats";
|
|
5
|
+
const fsRpcCodec = StringCodec();
|
|
6
|
+
function normalizeFsRpcPath(workspaceRoot, rawPath) {
|
|
7
|
+
const raw = typeof rawPath === "string" && rawPath.trim() ? rawPath.trim() : ".";
|
|
8
|
+
const normalizedRaw = raw.replace(/\\/g, "/");
|
|
9
|
+
const useAbsolute = path.isAbsolute(normalizedRaw);
|
|
10
|
+
const rel = normalizedRaw.replace(/^\/+/, "") || ".";
|
|
11
|
+
const abs = useAbsolute ? path.resolve(normalizedRaw) : path.resolve(workspaceRoot, rel);
|
|
12
|
+
if (!useAbsolute && abs !== workspaceRoot && !abs.startsWith(workspaceRoot + path.sep)) {
|
|
13
|
+
throw new Error("path escapes workspace root");
|
|
14
|
+
}
|
|
15
|
+
const formatPath = (target) => {
|
|
16
|
+
if (useAbsolute) {
|
|
17
|
+
return target.split(path.sep).join("/") || "/";
|
|
18
|
+
}
|
|
19
|
+
return path.relative(workspaceRoot, target).split(path.sep).join("/") || ".";
|
|
20
|
+
};
|
|
21
|
+
return { abs, formatPath };
|
|
22
|
+
}
|
|
23
|
+
function parseFsRpcAction(value) {
|
|
24
|
+
if (value === "list" ||
|
|
25
|
+
value === "stat" ||
|
|
26
|
+
value === "fetch_file" ||
|
|
27
|
+
value === "read_text" ||
|
|
28
|
+
value === "read_file" ||
|
|
29
|
+
value === "write_file" ||
|
|
30
|
+
value === "download_file" ||
|
|
31
|
+
value === "delete_path" ||
|
|
32
|
+
value === "archive_dir" ||
|
|
33
|
+
value === "extract_archive") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
throw new Error("unsupported action");
|
|
37
|
+
}
|
|
38
|
+
function normalizeFsRpcNumber(value, fallback) {
|
|
39
|
+
const n = Number(value);
|
|
40
|
+
if (!Number.isFinite(n)) {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
return Math.floor(n);
|
|
44
|
+
}
|
|
45
|
+
function inferMimeType(filePath) {
|
|
46
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
47
|
+
if (ext === ".txt" || ext === ".md" || ext === ".log") {
|
|
48
|
+
return "text/plain";
|
|
49
|
+
}
|
|
50
|
+
if (ext === ".json") {
|
|
51
|
+
return "application/json";
|
|
52
|
+
}
|
|
53
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
54
|
+
return "text/javascript";
|
|
55
|
+
}
|
|
56
|
+
if (ext === ".ts" || ext === ".tsx") {
|
|
57
|
+
return "text/typescript";
|
|
58
|
+
}
|
|
59
|
+
if (ext === ".jsx") {
|
|
60
|
+
return "text/jsx";
|
|
61
|
+
}
|
|
62
|
+
if (ext === ".css") {
|
|
63
|
+
return "text/css";
|
|
64
|
+
}
|
|
65
|
+
if (ext === ".html" || ext === ".htm") {
|
|
66
|
+
return "text/html";
|
|
67
|
+
}
|
|
68
|
+
if (ext === ".xml") {
|
|
69
|
+
return "application/xml";
|
|
70
|
+
}
|
|
71
|
+
if (ext === ".svg") {
|
|
72
|
+
return "image/svg+xml";
|
|
73
|
+
}
|
|
74
|
+
if (ext === ".png") {
|
|
75
|
+
return "image/png";
|
|
76
|
+
}
|
|
77
|
+
if (ext === ".jpg" || ext === ".jpeg") {
|
|
78
|
+
return "image/jpeg";
|
|
79
|
+
}
|
|
80
|
+
if (ext === ".gif") {
|
|
81
|
+
return "image/gif";
|
|
82
|
+
}
|
|
83
|
+
if (ext === ".webp") {
|
|
84
|
+
return "image/webp";
|
|
85
|
+
}
|
|
86
|
+
if (ext === ".pdf") {
|
|
87
|
+
return "application/pdf";
|
|
88
|
+
}
|
|
89
|
+
return "application/octet-stream";
|
|
90
|
+
}
|
|
91
|
+
function normalizeArchiveRelativePath(value) {
|
|
92
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
93
|
+
if (!normalized || normalized.includes("..")) {
|
|
94
|
+
throw new Error("invalid archive entry path");
|
|
95
|
+
}
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
async function collectDirectoryFiles(absDir, rootDir = absDir) {
|
|
99
|
+
const rows = await readdir(absDir, { withFileTypes: true });
|
|
100
|
+
const files = [];
|
|
101
|
+
for (const row of rows.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
102
|
+
const child = path.join(absDir, row.name);
|
|
103
|
+
if (row.isDirectory()) {
|
|
104
|
+
files.push(...await collectDirectoryFiles(child, rootDir));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!row.isFile()) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const bytes = await readFile(child);
|
|
111
|
+
files.push({
|
|
112
|
+
relPath: normalizeArchiveRelativePath(path.relative(rootDir, child)),
|
|
113
|
+
contentBase64: Buffer.from(bytes).toString("base64"),
|
|
114
|
+
sizeBytes: bytes.byteLength,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
async function executeFsRpc(args) {
|
|
120
|
+
const action = parseFsRpcAction(args.request.action);
|
|
121
|
+
const { abs, formatPath } = normalizeFsRpcPath(args.workspaceRoot, args.request.path);
|
|
122
|
+
if (action === "stat") {
|
|
123
|
+
const entry = await stat(abs);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
action,
|
|
127
|
+
path: formatPath(abs),
|
|
128
|
+
kind: entry.isDirectory() ? "dir" : "file",
|
|
129
|
+
size: entry.size,
|
|
130
|
+
mtimeMs: entry.mtimeMs,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (action === "list") {
|
|
134
|
+
const entry = await stat(abs);
|
|
135
|
+
if (!entry.isDirectory()) {
|
|
136
|
+
throw new Error("path is not a directory");
|
|
137
|
+
}
|
|
138
|
+
const limit = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.limit, 200), 1000));
|
|
139
|
+
const rows = await readdir(abs, { withFileTypes: true });
|
|
140
|
+
const items = await Promise.all(rows.map(async (row) => {
|
|
141
|
+
const child = path.join(abs, row.name);
|
|
142
|
+
const childStat = await stat(child);
|
|
143
|
+
return {
|
|
144
|
+
name: row.name,
|
|
145
|
+
path: formatPath(child),
|
|
146
|
+
kind: row.isDirectory() ? "dir" : "file",
|
|
147
|
+
size: childStat.size,
|
|
148
|
+
mtimeMs: childStat.mtimeMs,
|
|
149
|
+
};
|
|
150
|
+
}));
|
|
151
|
+
items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1));
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
action,
|
|
155
|
+
path: formatPath(abs),
|
|
156
|
+
items: items.slice(0, limit),
|
|
157
|
+
truncated: items.length > limit,
|
|
158
|
+
total: items.length,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (action === "archive_dir") {
|
|
162
|
+
const entry = await stat(abs);
|
|
163
|
+
if (!entry.isDirectory()) {
|
|
164
|
+
throw new Error("path is not a directory");
|
|
165
|
+
}
|
|
166
|
+
const rawArchivePath = typeof args.request.archivePath === "string" ? args.request.archivePath : "";
|
|
167
|
+
if (!rawArchivePath) {
|
|
168
|
+
throw new Error("archivePath is required");
|
|
169
|
+
}
|
|
170
|
+
const archiveTarget = normalizeFsRpcPath(args.workspaceRoot, rawArchivePath);
|
|
171
|
+
const files = await collectDirectoryFiles(abs);
|
|
172
|
+
if (!files.some((file) => file.relPath === "SKILL.md")) {
|
|
173
|
+
throw new Error("Selected skill directory must contain SKILL.md");
|
|
174
|
+
}
|
|
175
|
+
const payload = gzipSync(Buffer.from(JSON.stringify({ files }), "utf8"));
|
|
176
|
+
await mkdir(path.dirname(archiveTarget.abs), { recursive: true });
|
|
177
|
+
await writeFile(archiveTarget.abs, payload);
|
|
178
|
+
const archiveStat = await stat(archiveTarget.abs);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
action,
|
|
182
|
+
path: formatPath(abs),
|
|
183
|
+
archivePath: archiveTarget.formatPath(archiveTarget.abs),
|
|
184
|
+
size: archiveStat.size,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (action === "fetch_file") {
|
|
188
|
+
const entry = await stat(abs);
|
|
189
|
+
if (!entry.isFile()) {
|
|
190
|
+
throw new Error("path is not a file");
|
|
191
|
+
}
|
|
192
|
+
const uploadUrl = typeof args.request.uploadUrl === "string" ? args.request.uploadUrl : "";
|
|
193
|
+
const agentId = typeof args.request.agentId === "string" ? args.request.agentId : "";
|
|
194
|
+
if (!uploadUrl || !agentId) {
|
|
195
|
+
throw new Error("missing upload parameters");
|
|
196
|
+
}
|
|
197
|
+
const resolvedUploadUrl = new URL(uploadUrl, `${args.serverBaseUrl}/`).toString();
|
|
198
|
+
const data = await readFile(abs);
|
|
199
|
+
const fileName = path.basename(abs) || "file";
|
|
200
|
+
const form = new FormData();
|
|
201
|
+
form.append("file", new File([data], fileName));
|
|
202
|
+
form.append("agentId", agentId);
|
|
203
|
+
const response = await fetch(resolvedUploadUrl, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { Authorization: `Bearer ${args.agentToken}` },
|
|
206
|
+
body: form,
|
|
207
|
+
});
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
let upload = {};
|
|
210
|
+
try {
|
|
211
|
+
upload = JSON.parse(text || "{}");
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
upload = {};
|
|
215
|
+
}
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
const message = typeof upload.error === "string" ? upload.error : `upload failed: ${response.status}`;
|
|
218
|
+
throw new Error(message);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
action,
|
|
223
|
+
path: formatPath(abs),
|
|
224
|
+
size: entry.size,
|
|
225
|
+
upload,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (action === "write_file") {
|
|
229
|
+
const contentBase64 = typeof args.request.contentBase64 === "string" ? args.request.contentBase64 : "";
|
|
230
|
+
if (!contentBase64) {
|
|
231
|
+
throw new Error("contentBase64 is required");
|
|
232
|
+
}
|
|
233
|
+
const parentDir = path.dirname(abs);
|
|
234
|
+
await mkdir(parentDir, { recursive: true });
|
|
235
|
+
const bytes = Buffer.from(contentBase64, "base64");
|
|
236
|
+
await writeFile(abs, bytes);
|
|
237
|
+
const entry = await stat(abs);
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
action,
|
|
241
|
+
path: formatPath(abs),
|
|
242
|
+
absolutePath: abs.split(path.sep).join("/"),
|
|
243
|
+
size: entry.size,
|
|
244
|
+
mimeType: inferMimeType(abs),
|
|
245
|
+
mtimeMs: entry.mtimeMs,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (action === "delete_path") {
|
|
249
|
+
await rm(abs, { recursive: true, force: true });
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
action,
|
|
253
|
+
path: formatPath(abs),
|
|
254
|
+
absolutePath: abs.split(path.sep).join("/"),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (action === "download_file") {
|
|
258
|
+
const downloadPath = typeof args.request.downloadPath === "string" ? args.request.downloadPath.trim() : "";
|
|
259
|
+
if (!downloadPath) {
|
|
260
|
+
throw new Error("downloadPath is required");
|
|
261
|
+
}
|
|
262
|
+
const downloadUrl = new URL(downloadPath, `${args.serverBaseUrl}/`).toString();
|
|
263
|
+
const response = await fetch(downloadUrl, {
|
|
264
|
+
method: "GET",
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${args.agentToken}`,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
const text = await response.text().catch(() => "");
|
|
271
|
+
throw new Error(text || `download failed: ${response.status}`);
|
|
272
|
+
}
|
|
273
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
274
|
+
const parentDir = path.dirname(abs);
|
|
275
|
+
await mkdir(parentDir, { recursive: true });
|
|
276
|
+
await writeFile(abs, bytes);
|
|
277
|
+
const entry = await stat(abs);
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
action,
|
|
281
|
+
path: formatPath(abs),
|
|
282
|
+
absolutePath: abs.split(path.sep).join("/"),
|
|
283
|
+
size: entry.size,
|
|
284
|
+
mimeType: inferMimeType(abs),
|
|
285
|
+
mtimeMs: entry.mtimeMs,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (action === "extract_archive") {
|
|
289
|
+
const archiveEntry = await stat(abs);
|
|
290
|
+
if (!archiveEntry.isFile()) {
|
|
291
|
+
throw new Error("path is not a file");
|
|
292
|
+
}
|
|
293
|
+
const rawDestinationPath = typeof args.request.destinationPath === "string" ? args.request.destinationPath : "";
|
|
294
|
+
if (!rawDestinationPath) {
|
|
295
|
+
throw new Error("destinationPath is required");
|
|
296
|
+
}
|
|
297
|
+
const destinationTarget = normalizeFsRpcPath(args.workspaceRoot, rawDestinationPath);
|
|
298
|
+
const archiveBytes = await readFile(abs);
|
|
299
|
+
const decoded = JSON.parse(gunzipSync(archiveBytes).toString("utf8"));
|
|
300
|
+
const files = Array.isArray(decoded.files) ? decoded.files : [];
|
|
301
|
+
await mkdir(destinationTarget.abs, { recursive: true });
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
const relPath = typeof file.relPath === "string" ? normalizeArchiveRelativePath(file.relPath) : "";
|
|
304
|
+
const contentBase64 = typeof file.contentBase64 === "string" ? file.contentBase64 : "";
|
|
305
|
+
if (!relPath || !contentBase64) {
|
|
306
|
+
throw new Error("archive contains an invalid file entry");
|
|
307
|
+
}
|
|
308
|
+
const targetPath = path.join(destinationTarget.abs, relPath);
|
|
309
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
310
|
+
await writeFile(targetPath, Buffer.from(contentBase64, "base64"));
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
action,
|
|
315
|
+
path: formatPath(abs),
|
|
316
|
+
absolutePath: destinationTarget.formatPath(destinationTarget.abs),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const entry = await stat(abs);
|
|
320
|
+
if (!entry.isFile()) {
|
|
321
|
+
throw new Error("path is not a file");
|
|
322
|
+
}
|
|
323
|
+
if (action === "read_file") {
|
|
324
|
+
const maxBytes = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.maxBytes, 2_000_000), 5_000_000));
|
|
325
|
+
const data = await readFile(abs);
|
|
326
|
+
const truncated = data.byteLength > maxBytes;
|
|
327
|
+
const bytes = truncated ? data.subarray(0, maxBytes) : data;
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
action,
|
|
331
|
+
path: formatPath(abs),
|
|
332
|
+
mimeType: inferMimeType(abs),
|
|
333
|
+
size: entry.size,
|
|
334
|
+
truncated,
|
|
335
|
+
contentBase64: bytes.toString("base64"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const offset = Math.max(0, normalizeFsRpcNumber(args.request.offset, 0));
|
|
339
|
+
const length = Math.max(1, Math.min(normalizeFsRpcNumber(args.request.length, 65536), 262144));
|
|
340
|
+
const encoding = typeof args.request.encoding === "string" && args.request.encoding ? args.request.encoding : "utf8";
|
|
341
|
+
const fd = await open(abs, "r");
|
|
342
|
+
try {
|
|
343
|
+
const buffer = Buffer.alloc(length);
|
|
344
|
+
const readResult = await fd.read(buffer, 0, length, offset);
|
|
345
|
+
const slice = buffer.subarray(0, readResult.bytesRead);
|
|
346
|
+
try {
|
|
347
|
+
const text = slice.toString(encoding);
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
action,
|
|
351
|
+
path: formatPath(abs),
|
|
352
|
+
offset,
|
|
353
|
+
length: readResult.bytesRead,
|
|
354
|
+
totalSize: entry.size,
|
|
355
|
+
eof: offset + readResult.bytesRead >= entry.size,
|
|
356
|
+
encoding,
|
|
357
|
+
text,
|
|
358
|
+
bytesRead: readResult.bytesRead,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
const message = error instanceof Error ? error.message : "failed to decode text";
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
action,
|
|
366
|
+
path: formatPath(abs),
|
|
367
|
+
error: message,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await fd.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
export async function handleFsRpcMessage(args) {
|
|
376
|
+
let payload = {};
|
|
377
|
+
try {
|
|
378
|
+
payload = JSON.parse(fsRpcCodec.decode(args.msg.data));
|
|
379
|
+
if (typeof payload.agentId === "string" && payload.agentId.trim() && payload.agentId !== args.agentId) {
|
|
380
|
+
throw new Error("agent id mismatch");
|
|
381
|
+
}
|
|
382
|
+
const result = await executeFsRpc({
|
|
383
|
+
workspaceRoot: args.workspaceRoot,
|
|
384
|
+
request: payload,
|
|
385
|
+
serverBaseUrl: args.serverBaseUrl,
|
|
386
|
+
agentToken: args.agentToken,
|
|
387
|
+
});
|
|
388
|
+
args.msg.respond(fsRpcCodec.encode(JSON.stringify(result)));
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
392
|
+
const action = typeof payload.action === "string" ? payload.action : "";
|
|
393
|
+
const response = {
|
|
394
|
+
ok: false,
|
|
395
|
+
action,
|
|
396
|
+
path: typeof payload.path === "string" ? payload.path : ".",
|
|
397
|
+
error: message,
|
|
398
|
+
};
|
|
399
|
+
args.msg.respond(fsRpcCodec.encode(JSON.stringify(response)));
|
|
400
|
+
args.onError(`fs rpc failed action=${action || "unknown"} error=${message}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { StringCodec } from "nats";
|
|
3
|
+
const gitRpcCodec = StringCodec();
|
|
4
|
+
function runLocalCommand(command, args, cwd) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
cwd,
|
|
8
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
9
|
+
});
|
|
10
|
+
let stdout = "";
|
|
11
|
+
let stderr = "";
|
|
12
|
+
child.stdout.setEncoding("utf8");
|
|
13
|
+
child.stderr.setEncoding("utf8");
|
|
14
|
+
child.stdout.on("data", (chunk) => {
|
|
15
|
+
stdout += chunk;
|
|
16
|
+
});
|
|
17
|
+
child.stderr.on("data", (chunk) => {
|
|
18
|
+
stderr += chunk;
|
|
19
|
+
});
|
|
20
|
+
child.once("error", reject);
|
|
21
|
+
child.once("close", (code) => {
|
|
22
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function sanitizeGitRef(value) {
|
|
27
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (trimmed.startsWith("-") || /\s/.test(trimmed) || trimmed.includes("..") || trimmed.includes(":")) {
|
|
32
|
+
throw new Error(`Invalid git ref: ${trimmed}`);
|
|
33
|
+
}
|
|
34
|
+
return trimmed;
|
|
35
|
+
}
|
|
36
|
+
function sanitizeGitPathspec(value) {
|
|
37
|
+
if (typeof value !== "string") {
|
|
38
|
+
throw new Error("Invalid pathspec");
|
|
39
|
+
}
|
|
40
|
+
const trimmed = value.trim().replace(/\\/g, "/");
|
|
41
|
+
if (!trimmed || trimmed.startsWith("-") || trimmed.includes("\0")) {
|
|
42
|
+
throw new Error(`Invalid pathspec: ${trimmed}`);
|
|
43
|
+
}
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
function normalizeGitRpcRequest(args) {
|
|
47
|
+
const requestId = typeof args.request.requestId === "string" ? args.request.requestId.trim() : "";
|
|
48
|
+
const responseSubject = typeof args.request.responseSubject === "string" ? args.request.responseSubject.trim() : "";
|
|
49
|
+
const requestAgentId = typeof args.request.agentId === "string" ? args.request.agentId.trim() : "";
|
|
50
|
+
const targetPath = typeof args.request.targetPath === "string" ? args.request.targetPath.trim() : "";
|
|
51
|
+
if (!requestId || !responseSubject || !requestAgentId || requestAgentId !== args.agentId || !targetPath) {
|
|
52
|
+
throw new Error("invalid git rpc request");
|
|
53
|
+
}
|
|
54
|
+
const format = args.request.format === "name-only" ||
|
|
55
|
+
args.request.format === "name-status" ||
|
|
56
|
+
args.request.format === "stat" ||
|
|
57
|
+
args.request.format === "numstat" ||
|
|
58
|
+
args.request.format === "raw"
|
|
59
|
+
? args.request.format
|
|
60
|
+
: "patch";
|
|
61
|
+
const ignoreWhitespace = args.request.ignoreWhitespace === "at-eol" ||
|
|
62
|
+
args.request.ignoreWhitespace === "change" ||
|
|
63
|
+
args.request.ignoreWhitespace === "all"
|
|
64
|
+
? args.request.ignoreWhitespace
|
|
65
|
+
: "none";
|
|
66
|
+
const diffAlgorithm = args.request.diffAlgorithm === "minimal" ||
|
|
67
|
+
args.request.diffAlgorithm === "patience" ||
|
|
68
|
+
args.request.diffAlgorithm === "histogram"
|
|
69
|
+
? args.request.diffAlgorithm
|
|
70
|
+
: "default";
|
|
71
|
+
const contextRaw = Number(args.request.contextLines);
|
|
72
|
+
const contextLines = Number.isFinite(contextRaw) ? Math.max(0, Math.min(200, Math.trunc(contextRaw))) : null;
|
|
73
|
+
const pathspecs = Array.isArray(args.request.pathspecs) ? args.request.pathspecs.map((item) => sanitizeGitPathspec(item)) : [];
|
|
74
|
+
return {
|
|
75
|
+
requestId,
|
|
76
|
+
responseSubject,
|
|
77
|
+
targetPath,
|
|
78
|
+
base: sanitizeGitRef(args.request.base),
|
|
79
|
+
target: sanitizeGitRef(args.request.target),
|
|
80
|
+
mergeBase: args.request.mergeBase === true,
|
|
81
|
+
staged: args.request.staged === true,
|
|
82
|
+
format,
|
|
83
|
+
contextLines,
|
|
84
|
+
ignoreWhitespace,
|
|
85
|
+
diffAlgorithm,
|
|
86
|
+
findRenames: args.request.findRenames === true,
|
|
87
|
+
pathspecs,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function buildAgentGitDiffArgs(repoRootAbs, request) {
|
|
91
|
+
const args = ["-C", repoRootAbs, "diff", "--no-color"];
|
|
92
|
+
const displayParts = ["git", "diff", "--no-color"];
|
|
93
|
+
if (request.staged) {
|
|
94
|
+
args.push("--cached");
|
|
95
|
+
displayParts.push("--cached");
|
|
96
|
+
}
|
|
97
|
+
if (typeof request.contextLines === "number") {
|
|
98
|
+
args.push(`-U${request.contextLines}`);
|
|
99
|
+
displayParts.push(`-U${request.contextLines}`);
|
|
100
|
+
}
|
|
101
|
+
if (request.ignoreWhitespace === "at-eol") {
|
|
102
|
+
args.push("--ignore-space-at-eol");
|
|
103
|
+
displayParts.push("--ignore-space-at-eol");
|
|
104
|
+
}
|
|
105
|
+
else if (request.ignoreWhitespace === "change") {
|
|
106
|
+
args.push("--ignore-space-change");
|
|
107
|
+
displayParts.push("--ignore-space-change");
|
|
108
|
+
}
|
|
109
|
+
else if (request.ignoreWhitespace === "all") {
|
|
110
|
+
args.push("--ignore-all-space");
|
|
111
|
+
displayParts.push("--ignore-all-space");
|
|
112
|
+
}
|
|
113
|
+
if (request.diffAlgorithm !== "default") {
|
|
114
|
+
args.push(`--diff-algorithm=${request.diffAlgorithm}`);
|
|
115
|
+
displayParts.push(`--diff-algorithm=${request.diffAlgorithm}`);
|
|
116
|
+
}
|
|
117
|
+
if (request.findRenames) {
|
|
118
|
+
args.push("--find-renames");
|
|
119
|
+
displayParts.push("--find-renames");
|
|
120
|
+
}
|
|
121
|
+
if (request.format === "name-only") {
|
|
122
|
+
args.push("--name-only");
|
|
123
|
+
displayParts.push("--name-only");
|
|
124
|
+
}
|
|
125
|
+
else if (request.format === "name-status") {
|
|
126
|
+
args.push("--name-status");
|
|
127
|
+
displayParts.push("--name-status");
|
|
128
|
+
}
|
|
129
|
+
else if (request.format === "stat") {
|
|
130
|
+
args.push("--stat");
|
|
131
|
+
displayParts.push("--stat");
|
|
132
|
+
}
|
|
133
|
+
else if (request.format === "numstat") {
|
|
134
|
+
args.push("--numstat");
|
|
135
|
+
displayParts.push("--numstat");
|
|
136
|
+
}
|
|
137
|
+
else if (request.format === "raw") {
|
|
138
|
+
args.push("--raw");
|
|
139
|
+
displayParts.push("--raw");
|
|
140
|
+
}
|
|
141
|
+
if (request.mergeBase) {
|
|
142
|
+
if (!request.base || !request.target) {
|
|
143
|
+
throw new Error("mergeBase mode requires both base and target");
|
|
144
|
+
}
|
|
145
|
+
const merged = `${request.base}...${request.target}`;
|
|
146
|
+
args.push(merged);
|
|
147
|
+
displayParts.push(merged);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
if (request.base) {
|
|
151
|
+
args.push(request.base);
|
|
152
|
+
displayParts.push(request.base);
|
|
153
|
+
}
|
|
154
|
+
if (request.target) {
|
|
155
|
+
args.push(request.target);
|
|
156
|
+
displayParts.push(request.target);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (request.pathspecs.length > 0) {
|
|
160
|
+
args.push("--", ...request.pathspecs);
|
|
161
|
+
displayParts.push("--", ...request.pathspecs);
|
|
162
|
+
}
|
|
163
|
+
return { args, display: displayParts.join(" ") };
|
|
164
|
+
}
|
|
165
|
+
function buildUntrackedText(format, untrackedPaths) {
|
|
166
|
+
if (untrackedPaths.length === 0) {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
if (format === "name-status" || format === "raw") {
|
|
170
|
+
return `${untrackedPaths.map((item) => `??\t${item}`).join("\n")}\n`;
|
|
171
|
+
}
|
|
172
|
+
if (format === "name-only") {
|
|
173
|
+
return `${untrackedPaths.join("\n")}\n`;
|
|
174
|
+
}
|
|
175
|
+
return `\n# Untracked files\n${untrackedPaths.join("\n")}\n`;
|
|
176
|
+
}
|
|
177
|
+
async function appendAgentLocalUntrackedDiff(repoRootAbs, request, baseOutput) {
|
|
178
|
+
const listArgs = ["-C", repoRootAbs, "ls-files", "--others", "--exclude-standard"];
|
|
179
|
+
if (request.pathspecs.length > 0) {
|
|
180
|
+
listArgs.push("--", ...request.pathspecs);
|
|
181
|
+
}
|
|
182
|
+
const listResult = await runLocalCommand("git", listArgs, repoRootAbs);
|
|
183
|
+
if (listResult.code !== 0) {
|
|
184
|
+
return { output: baseOutput, hasUntracked: false };
|
|
185
|
+
}
|
|
186
|
+
const untrackedPaths = listResult.stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
|
187
|
+
if (untrackedPaths.length === 0) {
|
|
188
|
+
return { output: baseOutput, hasUntracked: false };
|
|
189
|
+
}
|
|
190
|
+
if (request.format !== "patch") {
|
|
191
|
+
return { output: `${baseOutput}${buildUntrackedText(request.format, untrackedPaths)}`, hasUntracked: true };
|
|
192
|
+
}
|
|
193
|
+
let output = baseOutput;
|
|
194
|
+
for (const relPath of untrackedPaths) {
|
|
195
|
+
const diffResult = await runLocalCommand("git", ["-C", repoRootAbs, "diff", "--no-color", "--no-index", "--", "/dev/null", relPath], repoRootAbs);
|
|
196
|
+
if (diffResult.code !== 0 && diffResult.code !== 1) {
|
|
197
|
+
throw new Error(diffResult.stderr.trim() || `Failed to render agent untracked diff: ${relPath}`);
|
|
198
|
+
}
|
|
199
|
+
if (diffResult.stdout) {
|
|
200
|
+
output += diffResult.stdout;
|
|
201
|
+
if (!output.endsWith("\n")) {
|
|
202
|
+
output += "\n";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { output, hasUntracked: true };
|
|
207
|
+
}
|
|
208
|
+
function publishGitRpcResponse(args) {
|
|
209
|
+
args.nc.publish(args.responseSubject, gitRpcCodec.encode(JSON.stringify(args.payload)));
|
|
210
|
+
}
|
|
211
|
+
export async function handleGitRpcMessage(args) {
|
|
212
|
+
let requestId = "unknown";
|
|
213
|
+
let responseSubject = "";
|
|
214
|
+
try {
|
|
215
|
+
const payload = JSON.parse(gitRpcCodec.decode(args.msg.data));
|
|
216
|
+
const request = normalizeGitRpcRequest({ request: payload, agentId: args.agentId });
|
|
217
|
+
requestId = request.requestId;
|
|
218
|
+
responseSubject = request.responseSubject;
|
|
219
|
+
if (!request.targetPath.startsWith("/")) {
|
|
220
|
+
throw new Error("agent source requires an absolute directory path");
|
|
221
|
+
}
|
|
222
|
+
const topLevelResult = await runLocalCommand("git", ["-C", request.targetPath, "rev-parse", "--show-toplevel"], request.targetPath);
|
|
223
|
+
if (topLevelResult.code !== 0) {
|
|
224
|
+
publishGitRpcResponse({
|
|
225
|
+
nc: args.nc,
|
|
226
|
+
responseSubject,
|
|
227
|
+
payload: {
|
|
228
|
+
requestId,
|
|
229
|
+
ok: true,
|
|
230
|
+
payload: {
|
|
231
|
+
isGitRepo: false,
|
|
232
|
+
mode: "git_diff",
|
|
233
|
+
source: "agent",
|
|
234
|
+
agent: { id: args.agentId, name: null },
|
|
235
|
+
currentPath: request.targetPath,
|
|
236
|
+
repoRoot: null,
|
|
237
|
+
repoRelativePath: null,
|
|
238
|
+
branch: null,
|
|
239
|
+
gitDiff: {
|
|
240
|
+
command: "git diff --no-color",
|
|
241
|
+
format: "patch",
|
|
242
|
+
output: "",
|
|
243
|
+
outputTruncated: false,
|
|
244
|
+
},
|
|
245
|
+
message: "현재 경로가 Git 저장소가 아닙니다.",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const repoRootAbs = topLevelResult.stdout.trim();
|
|
252
|
+
const prefixResult = await runLocalCommand("git", ["-C", request.targetPath, "rev-parse", "--show-prefix"], request.targetPath);
|
|
253
|
+
const repoRelativePath = prefixResult.code === 0 ? (prefixResult.stdout.trim().replace(/\/$/, "") || ".") : ".";
|
|
254
|
+
const branchResult = await runLocalCommand("git", ["-C", repoRootAbs, "symbolic-ref", "--quiet", "--short", "HEAD"], repoRootAbs);
|
|
255
|
+
const detachedResult = branchResult.code === 0 ? null : await runLocalCommand("git", ["-C", repoRootAbs, "rev-parse", "--short", "HEAD"], repoRootAbs);
|
|
256
|
+
const branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : detachedResult && detachedResult.code === 0 ? detachedResult.stdout.trim() || null : null;
|
|
257
|
+
const gitDiffArgs = buildAgentGitDiffArgs(repoRootAbs, request);
|
|
258
|
+
const gitDiffResult = await runLocalCommand("git", gitDiffArgs.args, repoRootAbs);
|
|
259
|
+
if (gitDiffResult.code !== 0) {
|
|
260
|
+
throw new Error(gitDiffResult.stderr.trim() || "Failed to run agent git diff");
|
|
261
|
+
}
|
|
262
|
+
const withUntracked = await appendAgentLocalUntrackedDiff(repoRootAbs, request, gitDiffResult.stdout);
|
|
263
|
+
publishGitRpcResponse({
|
|
264
|
+
nc: args.nc,
|
|
265
|
+
responseSubject,
|
|
266
|
+
payload: {
|
|
267
|
+
requestId,
|
|
268
|
+
ok: true,
|
|
269
|
+
payload: {
|
|
270
|
+
isGitRepo: true,
|
|
271
|
+
mode: "git_diff",
|
|
272
|
+
source: "agent",
|
|
273
|
+
agent: { id: args.agentId, name: null },
|
|
274
|
+
currentPath: request.targetPath,
|
|
275
|
+
repoRoot: repoRootAbs,
|
|
276
|
+
repoRelativePath,
|
|
277
|
+
branch,
|
|
278
|
+
gitDiff: {
|
|
279
|
+
command: withUntracked.hasUntracked ? `${gitDiffArgs.display} (+ untracked)` : gitDiffArgs.display,
|
|
280
|
+
format: request.format,
|
|
281
|
+
output: withUntracked.output,
|
|
282
|
+
outputTruncated: false,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
if (responseSubject) {
|
|
291
|
+
publishGitRpcResponse({
|
|
292
|
+
nc: args.nc,
|
|
293
|
+
responseSubject,
|
|
294
|
+
payload: { requestId, ok: false, error: message },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
args.onError(`git rpc failed requestId=${requestId} error=${message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|