@jx-grxf/patchpilot 0.2.0 → 0.3.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 +67 -10
- package/SECURITY.md +20 -0
- package/dist/cli.js +52 -3
- package/dist/cli.js.map +1 -1
- package/dist/core/agent.d.ts +5 -2
- package/dist/core/agent.js +167 -24
- package/dist/core/agent.js.map +1 -1
- package/dist/core/codex.js +1 -1
- package/dist/core/codex.js.map +1 -1
- package/dist/core/gemini.js +13 -22
- package/dist/core/gemini.js.map +1 -1
- package/dist/core/http.d.ts +6 -0
- package/dist/core/http.js +45 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/json.js +9 -0
- package/dist/core/json.js.map +1 -1
- package/dist/core/nvidia.js +9 -2
- package/dist/core/nvidia.js.map +1 -1
- package/dist/core/ollama.js +8 -1
- package/dist/core/ollama.js.map +1 -1
- package/dist/core/openrouter.js +13 -8
- package/dist/core/openrouter.js.map +1 -1
- package/dist/core/reasoning.d.ts +12 -0
- package/dist/core/reasoning.js +108 -0
- package/dist/core/reasoning.js.map +1 -0
- package/dist/core/session.d.ts +31 -0
- package/dist/core/session.js +154 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/types.d.ts +103 -2
- package/dist/core/workspace.d.ts +17 -1
- package/dist/core/workspace.js +495 -13
- package/dist/core/workspace.js.map +1 -1
- package/dist/tui/App.js +291 -88
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/commands.js +37 -2
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/components/Header.d.ts +2 -2
- package/dist/tui/components/Header.js +17 -54
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/OnboardingPanel.d.ts +5 -0
- package/dist/tui/components/OnboardingPanel.js +11 -13
- package/dist/tui/components/OnboardingPanel.js.map +1 -1
- package/dist/tui/components/Sidebar.d.ts +6 -1
- package/dist/tui/components/Sidebar.js +15 -6
- package/dist/tui/components/Sidebar.js.map +1 -1
- package/dist/tui/components/Transcript.js +57 -8
- package/dist/tui/components/Transcript.js.map +1 -1
- package/dist/tui/hosts.js +7 -1
- package/dist/tui/hosts.js.map +1 -1
- package/dist/tui/modelSelection.d.ts +1 -0
- package/dist/tui/modelSelection.js +29 -0
- package/dist/tui/modelSelection.js.map +1 -0
- package/dist/tui/types.d.ts +12 -2
- package/dist/tui/types.js.map +1 -1
- package/docs/releases/v0.1.0.md +26 -0
- package/docs/releases/v0.2.0.md +21 -0
- package/docs/releases/v0.2.1.md +26 -0
- package/docs/releases/v0.3.0.md +26 -0
- package/docs/showcase/patchpilot-showcase.svg +83 -38
- package/package.json +5 -2
- package/dist/tui/inputRouting.d.ts +0 -8
- package/dist/tui/inputRouting.js +0 -94
- package/dist/tui/inputRouting.js.map +0 -1
package/dist/core/workspace.js
CHANGED
|
@@ -8,6 +8,7 @@ import { inflateRawSync } from "node:zlib";
|
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
9
|
const ignoredDirectories = new Set([
|
|
10
10
|
".git",
|
|
11
|
+
".patchpilot",
|
|
11
12
|
"node_modules",
|
|
12
13
|
"dist",
|
|
13
14
|
"coverage",
|
|
@@ -67,6 +68,131 @@ const blockedPathNames = new Set([
|
|
|
67
68
|
"id_ed25519",
|
|
68
69
|
"known_hosts"
|
|
69
70
|
]);
|
|
71
|
+
export const toolSpecs = {
|
|
72
|
+
list_files: {
|
|
73
|
+
name: "list_files",
|
|
74
|
+
description: "List workspace files under a directory.",
|
|
75
|
+
risk: "low",
|
|
76
|
+
sideEffects: "none",
|
|
77
|
+
permission: "none",
|
|
78
|
+
category: "read"
|
|
79
|
+
},
|
|
80
|
+
read_file: {
|
|
81
|
+
name: "read_file",
|
|
82
|
+
description: "Read a complete text/code file.",
|
|
83
|
+
risk: "low",
|
|
84
|
+
sideEffects: "none",
|
|
85
|
+
permission: "none",
|
|
86
|
+
category: "read"
|
|
87
|
+
},
|
|
88
|
+
read_range: {
|
|
89
|
+
name: "read_range",
|
|
90
|
+
description: "Read a bounded 1-based line range from a text/code file.",
|
|
91
|
+
risk: "low",
|
|
92
|
+
sideEffects: "none",
|
|
93
|
+
permission: "none",
|
|
94
|
+
category: "read"
|
|
95
|
+
},
|
|
96
|
+
file_info: {
|
|
97
|
+
name: "file_info",
|
|
98
|
+
description: "Inspect file metadata inside the workspace.",
|
|
99
|
+
risk: "low",
|
|
100
|
+
sideEffects: "none",
|
|
101
|
+
permission: "none",
|
|
102
|
+
category: "read"
|
|
103
|
+
},
|
|
104
|
+
search_text: {
|
|
105
|
+
name: "search_text",
|
|
106
|
+
description: "Search workspace text with ripgrep.",
|
|
107
|
+
risk: "low",
|
|
108
|
+
sideEffects: "none",
|
|
109
|
+
permission: "none",
|
|
110
|
+
category: "search"
|
|
111
|
+
},
|
|
112
|
+
inspect_document: {
|
|
113
|
+
name: "inspect_document",
|
|
114
|
+
description: "Extract text from supported documents.",
|
|
115
|
+
risk: "low",
|
|
116
|
+
sideEffects: "none",
|
|
117
|
+
permission: "none",
|
|
118
|
+
category: "document"
|
|
119
|
+
},
|
|
120
|
+
git_status: {
|
|
121
|
+
name: "git_status",
|
|
122
|
+
description: "Read the current Git branch and dirty state.",
|
|
123
|
+
risk: "low",
|
|
124
|
+
sideEffects: "none",
|
|
125
|
+
permission: "none",
|
|
126
|
+
category: "git"
|
|
127
|
+
},
|
|
128
|
+
git_diff: {
|
|
129
|
+
name: "git_diff",
|
|
130
|
+
description: "Read the current Git diff.",
|
|
131
|
+
risk: "low",
|
|
132
|
+
sideEffects: "none",
|
|
133
|
+
permission: "none",
|
|
134
|
+
category: "git"
|
|
135
|
+
},
|
|
136
|
+
list_changed_files: {
|
|
137
|
+
name: "list_changed_files",
|
|
138
|
+
description: "List changed files from Git porcelain status.",
|
|
139
|
+
risk: "low",
|
|
140
|
+
sideEffects: "none",
|
|
141
|
+
permission: "none",
|
|
142
|
+
category: "git"
|
|
143
|
+
},
|
|
144
|
+
list_scripts: {
|
|
145
|
+
name: "list_scripts",
|
|
146
|
+
description: "List package.json scripts.",
|
|
147
|
+
risk: "low",
|
|
148
|
+
sideEffects: "none",
|
|
149
|
+
permission: "none",
|
|
150
|
+
category: "read"
|
|
151
|
+
},
|
|
152
|
+
write_file: {
|
|
153
|
+
name: "write_file",
|
|
154
|
+
description: "Write a full file in the workspace.",
|
|
155
|
+
risk: "high",
|
|
156
|
+
sideEffects: "write",
|
|
157
|
+
permission: "write",
|
|
158
|
+
category: "write"
|
|
159
|
+
},
|
|
160
|
+
apply_patch: {
|
|
161
|
+
name: "apply_patch",
|
|
162
|
+
description: "Apply a unified Git patch inside the workspace.",
|
|
163
|
+
risk: "high",
|
|
164
|
+
sideEffects: "write",
|
|
165
|
+
permission: "write",
|
|
166
|
+
category: "write"
|
|
167
|
+
},
|
|
168
|
+
run_script: {
|
|
169
|
+
name: "run_script",
|
|
170
|
+
description: "Run a named package.json script.",
|
|
171
|
+
risk: "medium",
|
|
172
|
+
sideEffects: "shell",
|
|
173
|
+
permission: "shell",
|
|
174
|
+
category: "shell"
|
|
175
|
+
},
|
|
176
|
+
run_tests: {
|
|
177
|
+
name: "run_tests",
|
|
178
|
+
description: "Run the repository test script.",
|
|
179
|
+
risk: "medium",
|
|
180
|
+
sideEffects: "shell",
|
|
181
|
+
permission: "shell",
|
|
182
|
+
category: "test"
|
|
183
|
+
},
|
|
184
|
+
run_shell: {
|
|
185
|
+
name: "run_shell",
|
|
186
|
+
description: "Run a restricted one-line shell command.",
|
|
187
|
+
risk: "high",
|
|
188
|
+
sideEffects: "shell",
|
|
189
|
+
permission: "shell",
|
|
190
|
+
category: "shell"
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
export function getToolSpec(name) {
|
|
194
|
+
return toolSpecs[name];
|
|
195
|
+
}
|
|
70
196
|
export class WorkspaceTools {
|
|
71
197
|
root;
|
|
72
198
|
rootRealPath;
|
|
@@ -74,6 +200,8 @@ export class WorkspaceTools {
|
|
|
74
200
|
allowShell;
|
|
75
201
|
timeoutMs;
|
|
76
202
|
signal;
|
|
203
|
+
approvalHandler;
|
|
204
|
+
sessionApprovals = new Set();
|
|
77
205
|
constructor(options) {
|
|
78
206
|
this.root = path.resolve(options.root);
|
|
79
207
|
this.rootRealPath = realpath(this.root).catch(() => this.root);
|
|
@@ -81,6 +209,7 @@ export class WorkspaceTools {
|
|
|
81
209
|
this.allowShell = options.allowShell;
|
|
82
210
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
83
211
|
this.signal = options.signal;
|
|
212
|
+
this.approvalHandler = options.approvalHandler;
|
|
84
213
|
}
|
|
85
214
|
async execute(call) {
|
|
86
215
|
try {
|
|
@@ -89,12 +218,30 @@ export class WorkspaceTools {
|
|
|
89
218
|
return await this.listFiles(readString(call.arguments.path, "."));
|
|
90
219
|
case "read_file":
|
|
91
220
|
return await this.readFile(readString(call.arguments.path, ""));
|
|
221
|
+
case "read_range":
|
|
222
|
+
return await this.readRange(readString(call.arguments.path, ""), readNumber(call.arguments.start, 1), readNumber(call.arguments.end, readNumber(call.arguments.start, 1) + 80));
|
|
223
|
+
case "file_info":
|
|
224
|
+
return await this.fileInfo(readString(call.arguments.path, ""));
|
|
92
225
|
case "search_text":
|
|
93
226
|
return await this.searchText(readString(call.arguments.query, ""));
|
|
94
227
|
case "inspect_document":
|
|
95
228
|
return await this.inspectDocument(readString(call.arguments.path, ""));
|
|
229
|
+
case "git_status":
|
|
230
|
+
return await this.gitStatus();
|
|
231
|
+
case "git_diff":
|
|
232
|
+
return await this.gitDiff(readString(call.arguments.path, ""));
|
|
233
|
+
case "list_changed_files":
|
|
234
|
+
return await this.listChangedFiles();
|
|
235
|
+
case "list_scripts":
|
|
236
|
+
return await this.listScripts();
|
|
96
237
|
case "write_file":
|
|
97
238
|
return await this.writeFile(readString(call.arguments.path, ""), readString(call.arguments.content, ""));
|
|
239
|
+
case "apply_patch":
|
|
240
|
+
return await this.applyPatch(readString(call.arguments.patch, ""));
|
|
241
|
+
case "run_script":
|
|
242
|
+
return await this.runScript(readString(call.arguments.script, ""));
|
|
243
|
+
case "run_tests":
|
|
244
|
+
return await this.runTests();
|
|
98
245
|
case "run_shell":
|
|
99
246
|
return await this.runShell(readString(call.arguments.command, ""));
|
|
100
247
|
default:
|
|
@@ -144,7 +291,9 @@ export class WorkspaceTools {
|
|
|
144
291
|
return {
|
|
145
292
|
ok: true,
|
|
146
293
|
summary: `listed ${entries.length} files`,
|
|
147
|
-
content: entries.join("\n")
|
|
294
|
+
content: entries.join("\n"),
|
|
295
|
+
tool: "list_files",
|
|
296
|
+
category: toolSpecs.list_files.category
|
|
148
297
|
};
|
|
149
298
|
}
|
|
150
299
|
async readFile(requestedPath) {
|
|
@@ -168,7 +317,70 @@ export class WorkspaceTools {
|
|
|
168
317
|
return {
|
|
169
318
|
ok: true,
|
|
170
319
|
summary: `read ${path.relative(this.root, absolutePath)}`,
|
|
171
|
-
content: clippedContent
|
|
320
|
+
content: clippedContent,
|
|
321
|
+
tool: "read_file",
|
|
322
|
+
category: toolSpecs.read_file.category
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
async readRange(requestedPath, startLine, endLine) {
|
|
326
|
+
if (!requestedPath) {
|
|
327
|
+
return denied("read_range requires a path.", "read_range");
|
|
328
|
+
}
|
|
329
|
+
if (startLine < 1 || endLine < startLine) {
|
|
330
|
+
return denied("read_range requires 1-based start/end lines.", "read_range");
|
|
331
|
+
}
|
|
332
|
+
if (isPlaceholderPath(requestedPath)) {
|
|
333
|
+
return denied(`read_range denied placeholder path: ${requestedPath}`, "read_range");
|
|
334
|
+
}
|
|
335
|
+
if (isSensitivePath(requestedPath)) {
|
|
336
|
+
return denied(`read_range denied sensitive path: ${requestedPath}`, "read_range");
|
|
337
|
+
}
|
|
338
|
+
const absolutePath = await this.resolveReadPath(requestedPath);
|
|
339
|
+
if (!isLikelyTextFile(absolutePath)) {
|
|
340
|
+
return denied(`read_range supports text/code files. Use inspect_document for ${path.extname(absolutePath) || "this file"} files.`, "read_range");
|
|
341
|
+
}
|
|
342
|
+
const lines = (await readFile(absolutePath, "utf8")).split(/\r?\n/);
|
|
343
|
+
const selectedLines = lines.slice(startLine - 1, endLine);
|
|
344
|
+
const numberedLines = selectedLines.map((line, index) => `${startLine + index}: ${line}`).join("\n");
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
summary: `read ${path.relative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
|
|
348
|
+
content: clip(numberedLines || "No lines in range.", 20_000),
|
|
349
|
+
tool: "read_range",
|
|
350
|
+
category: toolSpecs.read_range.category,
|
|
351
|
+
metadata: {
|
|
352
|
+
path: path.relative(this.root, absolutePath),
|
|
353
|
+
startLine,
|
|
354
|
+
endLine: Math.min(endLine, lines.length)
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
async fileInfo(requestedPath) {
|
|
359
|
+
if (!requestedPath) {
|
|
360
|
+
return denied("file_info requires a path.", "file_info");
|
|
361
|
+
}
|
|
362
|
+
if (isSensitivePath(requestedPath)) {
|
|
363
|
+
return denied(`file_info denied sensitive path: ${requestedPath}`, "file_info");
|
|
364
|
+
}
|
|
365
|
+
const absolutePath = await this.resolveReadPath(requestedPath);
|
|
366
|
+
const fileStat = await stat(absolutePath);
|
|
367
|
+
const relativePath = path.relative(this.root, absolutePath);
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
summary: `inspected ${relativePath}`,
|
|
371
|
+
content: [
|
|
372
|
+
`path: ${relativePath}`,
|
|
373
|
+
`type: ${fileStat.isDirectory() ? "directory" : fileStat.isFile() ? "file" : "other"}`,
|
|
374
|
+
`size: ${fileStat.size} bytes`,
|
|
375
|
+
`modified: ${fileStat.mtime.toISOString()}`
|
|
376
|
+
].join("\n"),
|
|
377
|
+
tool: "file_info",
|
|
378
|
+
category: toolSpecs.file_info.category,
|
|
379
|
+
metadata: {
|
|
380
|
+
path: relativePath,
|
|
381
|
+
size: fileStat.size,
|
|
382
|
+
modifiedAt: fileStat.mtime.toISOString()
|
|
383
|
+
}
|
|
172
384
|
};
|
|
173
385
|
}
|
|
174
386
|
async inspectDocument(requestedPath) {
|
|
@@ -198,7 +410,7 @@ export class WorkspaceTools {
|
|
|
198
410
|
if (!query.trim()) {
|
|
199
411
|
return denied("search_text requires a non-empty query.");
|
|
200
412
|
}
|
|
201
|
-
const ripgrepResult = await searchTextWithRipgrep(this.root, query);
|
|
413
|
+
const ripgrepResult = await searchTextWithRipgrep(this.root, query, this.timeoutMs, this.signal);
|
|
202
414
|
if (ripgrepResult) {
|
|
203
415
|
return ripgrepResult;
|
|
204
416
|
}
|
|
@@ -224,12 +436,20 @@ export class WorkspaceTools {
|
|
|
224
436
|
return {
|
|
225
437
|
ok: true,
|
|
226
438
|
summary: `found ${matches.length} matches`,
|
|
227
|
-
content: matches.join("\n") || "No matches."
|
|
439
|
+
content: matches.join("\n") || "No matches.",
|
|
440
|
+
tool: "search_text",
|
|
441
|
+
category: toolSpecs.search_text.category
|
|
228
442
|
};
|
|
229
443
|
}
|
|
230
444
|
async writeFile(requestedPath, content) {
|
|
231
445
|
if (!this.allowWrite) {
|
|
232
|
-
|
|
446
|
+
const approval = await this.requestApproval("write_file", "write", {
|
|
447
|
+
path: requestedPath,
|
|
448
|
+
contentLength: content.length
|
|
449
|
+
}, `Write ${requestedPath} (${content.length} characters).`);
|
|
450
|
+
if (approval.decision === "deny") {
|
|
451
|
+
return denied("write_file denied by permission policy. Restart with --apply or approve the request in build mode.", "write_file", approval);
|
|
452
|
+
}
|
|
233
453
|
}
|
|
234
454
|
if (!requestedPath) {
|
|
235
455
|
return denied("write_file requires a path.");
|
|
@@ -246,12 +466,153 @@ export class WorkspaceTools {
|
|
|
246
466
|
return {
|
|
247
467
|
ok: true,
|
|
248
468
|
summary: `wrote ${path.relative(this.root, absolutePath)}`,
|
|
249
|
-
content: `Wrote ${content.length} characters
|
|
469
|
+
content: `Wrote ${content.length} characters.`,
|
|
470
|
+
tool: "write_file",
|
|
471
|
+
category: toolSpecs.write_file.category,
|
|
472
|
+
preview: `Write ${path.relative(this.root, absolutePath)}`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
async gitStatus() {
|
|
476
|
+
const { stdout } = await execFileAsync("git", ["status", "--short", "--branch"], {
|
|
477
|
+
cwd: this.root,
|
|
478
|
+
timeout: Math.min(this.timeoutMs, 8000),
|
|
479
|
+
maxBuffer: 200_000,
|
|
480
|
+
signal: this.signal,
|
|
481
|
+
windowsHide: true
|
|
482
|
+
});
|
|
483
|
+
return {
|
|
484
|
+
ok: true,
|
|
485
|
+
summary: "read git status",
|
|
486
|
+
content: stdout.trim() || "No git status output.",
|
|
487
|
+
tool: "git_status",
|
|
488
|
+
category: toolSpecs.git_status.category
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async gitDiff(requestedPath) {
|
|
492
|
+
const args = ["diff", "--"];
|
|
493
|
+
if (requestedPath.trim()) {
|
|
494
|
+
const absolutePath = this.resolveInsideWorkspace(requestedPath);
|
|
495
|
+
args.push(path.relative(this.root, absolutePath));
|
|
496
|
+
}
|
|
497
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
498
|
+
cwd: this.root,
|
|
499
|
+
timeout: Math.min(this.timeoutMs, 8000),
|
|
500
|
+
maxBuffer: 1_000_000,
|
|
501
|
+
signal: this.signal,
|
|
502
|
+
windowsHide: true
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
summary: stdout.trim() ? "read git diff" : "no git diff",
|
|
507
|
+
content: clip(stdout.trim() || "No changes.", 20_000),
|
|
508
|
+
tool: "git_diff",
|
|
509
|
+
category: toolSpecs.git_diff.category
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async listChangedFiles() {
|
|
513
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
514
|
+
cwd: this.root,
|
|
515
|
+
timeout: Math.min(this.timeoutMs, 8000),
|
|
516
|
+
maxBuffer: 200_000,
|
|
517
|
+
signal: this.signal,
|
|
518
|
+
windowsHide: true
|
|
519
|
+
});
|
|
520
|
+
const files = stdout
|
|
521
|
+
.split(/\r?\n/)
|
|
522
|
+
.map((line) => line.trimEnd())
|
|
523
|
+
.filter(Boolean);
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
summary: `listed ${files.length} changed file${files.length === 1 ? "" : "s"}`,
|
|
527
|
+
content: files.join("\n") || "No changed files.",
|
|
528
|
+
tool: "list_changed_files",
|
|
529
|
+
category: toolSpecs.list_changed_files.category
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async listScripts() {
|
|
533
|
+
const packageJsonPath = await this.resolveReadPath("package.json");
|
|
534
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
535
|
+
const scripts = Object.entries(packageJson.scripts ?? {})
|
|
536
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
537
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
summary: `listed ${scripts.length} package scripts`,
|
|
541
|
+
content: scripts.map(([name, command]) => `${name}: ${command}`).join("\n") || "No package scripts found.",
|
|
542
|
+
tool: "list_scripts",
|
|
543
|
+
category: toolSpecs.list_scripts.category
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async applyPatch(patchContent) {
|
|
547
|
+
if (!patchContent.trim()) {
|
|
548
|
+
return denied("apply_patch requires a unified patch.", "apply_patch");
|
|
549
|
+
}
|
|
550
|
+
if (!this.allowWrite) {
|
|
551
|
+
const approval = await this.requestApproval("apply_patch", "write", {
|
|
552
|
+
patch: clip(patchContent, 1200)
|
|
553
|
+
}, previewPatch(patchContent));
|
|
554
|
+
if (approval.decision === "deny") {
|
|
555
|
+
return denied("apply_patch denied by permission policy.", "apply_patch", approval);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const output = await runGitApply(patchContent, this.root, this.timeoutMs, this.signal);
|
|
559
|
+
return {
|
|
560
|
+
ok: output.exitCode === 0,
|
|
561
|
+
summary: output.exitCode === 0 ? "applied patch" : `git apply exited ${output.exitCode}`,
|
|
562
|
+
content: clip(output.output || (output.exitCode === 0 ? "Patch applied." : "Patch failed."), 20_000),
|
|
563
|
+
tool: "apply_patch",
|
|
564
|
+
category: toolSpecs.apply_patch.category,
|
|
565
|
+
preview: previewPatch(patchContent)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async runScript(scriptName) {
|
|
569
|
+
const normalizedScript = scriptName.trim();
|
|
570
|
+
if (!/^[\w:.-]+$/.test(normalizedScript)) {
|
|
571
|
+
return denied("run_script requires a package script name such as test or build.", "run_script");
|
|
572
|
+
}
|
|
573
|
+
const scripts = await this.readPackageScripts();
|
|
574
|
+
if (!scripts[normalizedScript]) {
|
|
575
|
+
return denied(`package script not found: ${normalizedScript}`, "run_script");
|
|
576
|
+
}
|
|
577
|
+
if (!this.allowShell) {
|
|
578
|
+
const approval = await this.requestApproval("run_script", "shell", {
|
|
579
|
+
script: normalizedScript
|
|
580
|
+
}, `Run package script: npm run ${normalizedScript}`);
|
|
581
|
+
if (approval.decision === "deny") {
|
|
582
|
+
return denied("run_script denied by permission policy.", "run_script", approval);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const output = await runCommand(`npm run ${normalizedScript}`, this.root, this.timeoutMs, this.signal);
|
|
586
|
+
return {
|
|
587
|
+
ok: output.exitCode === 0,
|
|
588
|
+
summary: `npm run ${normalizedScript} exited ${output.exitCode}`,
|
|
589
|
+
content: clip(output.output, 20_000),
|
|
590
|
+
tool: "run_script",
|
|
591
|
+
category: toolSpecs.run_script.category,
|
|
592
|
+
preview: `npm run ${normalizedScript}`
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async runTests() {
|
|
596
|
+
const scripts = await this.readPackageScripts();
|
|
597
|
+
if (!scripts.test) {
|
|
598
|
+
return denied("No package test script found.", "run_tests");
|
|
599
|
+
}
|
|
600
|
+
const result = await this.runScript("test");
|
|
601
|
+
return {
|
|
602
|
+
...result,
|
|
603
|
+
tool: "run_tests",
|
|
604
|
+
category: toolSpecs.run_tests.category,
|
|
605
|
+
preview: "npm test"
|
|
250
606
|
};
|
|
251
607
|
}
|
|
252
608
|
async runShell(command) {
|
|
253
609
|
if (!this.allowShell) {
|
|
254
|
-
|
|
610
|
+
const approval = await this.requestApproval("run_shell", "shell", {
|
|
611
|
+
command
|
|
612
|
+
}, `Run shell command: ${command}`);
|
|
613
|
+
if (approval.decision === "deny") {
|
|
614
|
+
return denied("run_shell denied by permission policy.", "run_shell", approval);
|
|
615
|
+
}
|
|
255
616
|
}
|
|
256
617
|
if (!command.trim()) {
|
|
257
618
|
return denied("run_shell requires a command.");
|
|
@@ -264,7 +625,46 @@ export class WorkspaceTools {
|
|
|
264
625
|
return {
|
|
265
626
|
ok: output.exitCode === 0,
|
|
266
627
|
summary: `command exited ${output.exitCode}`,
|
|
267
|
-
content: clip(output.output, 20_000)
|
|
628
|
+
content: clip(output.output, 20_000),
|
|
629
|
+
tool: "run_shell",
|
|
630
|
+
category: toolSpecs.run_shell.category,
|
|
631
|
+
preview: command
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async readPackageScripts() {
|
|
635
|
+
const packageJsonPath = await this.resolveReadPath("package.json");
|
|
636
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
637
|
+
return Object.fromEntries(Object.entries(packageJson.scripts ?? {}).filter((entry) => typeof entry[1] === "string"));
|
|
638
|
+
}
|
|
639
|
+
async requestApproval(tool, permission, args, preview) {
|
|
640
|
+
const spec = getToolSpec(tool);
|
|
641
|
+
const request = {
|
|
642
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
643
|
+
tool,
|
|
644
|
+
permission,
|
|
645
|
+
risk: spec.risk,
|
|
646
|
+
preview,
|
|
647
|
+
arguments: args
|
|
648
|
+
};
|
|
649
|
+
if (this.sessionApprovals.has(permission)) {
|
|
650
|
+
return {
|
|
651
|
+
request,
|
|
652
|
+
decision: "allow_session"
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (!this.approvalHandler) {
|
|
656
|
+
return {
|
|
657
|
+
request,
|
|
658
|
+
decision: "deny"
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const decision = await this.approvalHandler(request);
|
|
662
|
+
if (decision === "allow_session") {
|
|
663
|
+
this.sessionApprovals.add(permission);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
request,
|
|
667
|
+
decision
|
|
268
668
|
};
|
|
269
669
|
}
|
|
270
670
|
async resolveReadPath(requestedPath) {
|
|
@@ -327,9 +727,10 @@ async function walkFiles(startPath, workspaceRoot, workspaceRealRoot, maxDepth,
|
|
|
327
727
|
await visit(startPath, 0);
|
|
328
728
|
return results;
|
|
329
729
|
}
|
|
330
|
-
async function searchTextWithRipgrep(workspaceRoot, query) {
|
|
730
|
+
async function searchTextWithRipgrep(workspaceRoot, query, timeoutMs, signal) {
|
|
331
731
|
const ignoreGlobs = [
|
|
332
732
|
"!.git/**",
|
|
733
|
+
"!.patchpilot/**",
|
|
333
734
|
"!node_modules/**",
|
|
334
735
|
"!dist/**",
|
|
335
736
|
"!coverage/**",
|
|
@@ -368,20 +769,31 @@ async function searchTextWithRipgrep(workspaceRoot, query) {
|
|
|
368
769
|
], {
|
|
369
770
|
cwd: workspaceRoot,
|
|
370
771
|
stdio: ["ignore", "pipe", "pipe"],
|
|
772
|
+
signal,
|
|
371
773
|
windowsHide: true
|
|
372
774
|
});
|
|
373
775
|
let stdout = "";
|
|
374
776
|
let stderr = "";
|
|
777
|
+
const timeout = setTimeout(() => {
|
|
778
|
+
child.kill();
|
|
779
|
+
resolve({
|
|
780
|
+
ok: false,
|
|
781
|
+
summary: "ripgrep search timed out",
|
|
782
|
+
content: `Search timed out after ${timeoutMs}ms. Narrow the query or path.`
|
|
783
|
+
});
|
|
784
|
+
}, timeoutMs);
|
|
375
785
|
child.stdout.on("data", (chunk) => {
|
|
376
786
|
stdout += chunk.toString("utf8");
|
|
377
787
|
});
|
|
378
788
|
child.stderr.on("data", (chunk) => {
|
|
379
789
|
stderr += chunk.toString("utf8");
|
|
380
790
|
});
|
|
381
|
-
child.on("error", () => {
|
|
382
|
-
|
|
791
|
+
child.on("error", (error) => {
|
|
792
|
+
clearTimeout(timeout);
|
|
793
|
+
resolve(error.name === "AbortError" ? denied("ripgrep search aborted.") : null);
|
|
383
794
|
});
|
|
384
795
|
child.on("close", (exitCode) => {
|
|
796
|
+
clearTimeout(timeout);
|
|
385
797
|
if (exitCode === 0 || exitCode === 1) {
|
|
386
798
|
const content = stdout.trim();
|
|
387
799
|
const lines = content ? content.split(/\r?\n/).slice(0, 80) : [];
|
|
@@ -458,9 +870,54 @@ function runCommand(command, cwd, timeoutMs, signal) {
|
|
|
458
870
|
});
|
|
459
871
|
});
|
|
460
872
|
}
|
|
873
|
+
function runGitApply(patchContent, cwd, timeoutMs, signal) {
|
|
874
|
+
return new Promise((resolve) => {
|
|
875
|
+
const child = spawn("git", ["apply", "--whitespace=nowarn", "-"], {
|
|
876
|
+
cwd,
|
|
877
|
+
signal,
|
|
878
|
+
windowsHide: true,
|
|
879
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
880
|
+
});
|
|
881
|
+
let output = "";
|
|
882
|
+
const timeout = setTimeout(() => {
|
|
883
|
+
output += `\nPatch timed out after ${timeoutMs}ms.`;
|
|
884
|
+
child.kill();
|
|
885
|
+
}, timeoutMs);
|
|
886
|
+
child.stdout.on("data", (chunk) => {
|
|
887
|
+
output += chunk.toString("utf8");
|
|
888
|
+
});
|
|
889
|
+
child.stderr.on("data", (chunk) => {
|
|
890
|
+
output += chunk.toString("utf8");
|
|
891
|
+
});
|
|
892
|
+
child.on("error", (error) => {
|
|
893
|
+
clearTimeout(timeout);
|
|
894
|
+
resolve({
|
|
895
|
+
exitCode: error.name === "AbortError" ? null : 1,
|
|
896
|
+
output: error.name === "AbortError" ? "Patch aborted." : error.message
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
child.on("close", (exitCode) => {
|
|
900
|
+
clearTimeout(timeout);
|
|
901
|
+
resolve({ exitCode, output });
|
|
902
|
+
});
|
|
903
|
+
child.stdin.end(patchContent);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
461
906
|
function readString(value, fallback) {
|
|
462
907
|
return typeof value === "string" ? value : fallback;
|
|
463
908
|
}
|
|
909
|
+
function readNumber(value, fallback) {
|
|
910
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
911
|
+
return Math.trunc(value);
|
|
912
|
+
}
|
|
913
|
+
if (typeof value === "string") {
|
|
914
|
+
const parsed = Number.parseInt(value, 10);
|
|
915
|
+
if (Number.isFinite(parsed)) {
|
|
916
|
+
return parsed;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return fallback;
|
|
920
|
+
}
|
|
464
921
|
function isPlaceholderPath(value) {
|
|
465
922
|
const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
|
|
466
923
|
return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
|
|
@@ -472,11 +929,14 @@ function isSensitivePath(value) {
|
|
|
472
929
|
.filter(Boolean)
|
|
473
930
|
.some((part) => blockedPathNames.has(part.toLowerCase()));
|
|
474
931
|
}
|
|
475
|
-
function denied(message) {
|
|
932
|
+
function denied(message, tool, approval) {
|
|
476
933
|
return {
|
|
477
934
|
ok: false,
|
|
478
935
|
summary: message,
|
|
479
|
-
content: message
|
|
936
|
+
content: message,
|
|
937
|
+
tool,
|
|
938
|
+
category: tool ? toolSpecs[tool].category : undefined,
|
|
939
|
+
approval
|
|
480
940
|
};
|
|
481
941
|
}
|
|
482
942
|
function isLikelyTextFile(filePath) {
|
|
@@ -586,6 +1046,18 @@ function clip(content, maxLength) {
|
|
|
586
1046
|
}
|
|
587
1047
|
return `${content.slice(0, maxLength)}\n...[clipped ${content.length - maxLength} chars]`;
|
|
588
1048
|
}
|
|
1049
|
+
function previewPatch(patchContent) {
|
|
1050
|
+
const changedFiles = patchContent
|
|
1051
|
+
.split(/\r?\n/)
|
|
1052
|
+
.filter((line) => line.startsWith("+++ ") || line.startsWith("--- "))
|
|
1053
|
+
.map((line) => line.slice(4).replace(/^a\//, "").replace(/^b\//, ""))
|
|
1054
|
+
.filter((file) => file !== "/dev/null");
|
|
1055
|
+
const uniqueFiles = [...new Set(changedFiles)].slice(0, 6);
|
|
1056
|
+
const fileSummary = uniqueFiles.length > 0 ? uniqueFiles.join(", ") : "unknown files";
|
|
1057
|
+
const added = patchContent.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
|
|
1058
|
+
const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
|
|
1059
|
+
return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
|
|
1060
|
+
}
|
|
589
1061
|
function validateShellCommand(command) {
|
|
590
1062
|
const trimmedCommand = command.trim();
|
|
591
1063
|
if (/[;&|><`$\n\r]/.test(trimmedCommand)) {
|
|
@@ -596,9 +1068,19 @@ function validateShellCommand(command) {
|
|
|
596
1068
|
return "command is empty.";
|
|
597
1069
|
}
|
|
598
1070
|
const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
|
|
1071
|
+
const subcommand = stripQuotes(tokens[1] ?? "").toLowerCase();
|
|
599
1072
|
if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe", "python", "python3", "node", "ruby", "perl"].includes(executable)) {
|
|
600
1073
|
return `executable "${executable}" is blocked.`;
|
|
601
1074
|
}
|
|
1075
|
+
if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
|
|
1076
|
+
return `destructive ${executable} flags are blocked.`;
|
|
1077
|
+
}
|
|
1078
|
+
if (executable === "git" && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
|
|
1079
|
+
return `git ${subcommand} is blocked in the shell tool.`;
|
|
1080
|
+
}
|
|
1081
|
+
if (executable === "npm" && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
|
|
1082
|
+
return `npm ${subcommand} is blocked in the shell tool.`;
|
|
1083
|
+
}
|
|
602
1084
|
for (const token of tokens.slice(1)) {
|
|
603
1085
|
const normalizedToken = stripQuotes(token);
|
|
604
1086
|
if (normalizedToken.startsWith("/") || normalizedToken.startsWith("~")) {
|