@jx-grxf/patchpilot 0.2.1 → 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.
Files changed (63) hide show
  1. package/README.md +67 -10
  2. package/SECURITY.md +20 -0
  3. package/dist/cli.js +52 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/core/agent.d.ts +5 -2
  6. package/dist/core/agent.js +167 -24
  7. package/dist/core/agent.js.map +1 -1
  8. package/dist/core/codex.js +1 -1
  9. package/dist/core/codex.js.map +1 -1
  10. package/dist/core/gemini.js +8 -21
  11. package/dist/core/gemini.js.map +1 -1
  12. package/dist/core/http.d.ts +6 -0
  13. package/dist/core/http.js +45 -0
  14. package/dist/core/http.js.map +1 -0
  15. package/dist/core/json.js +9 -0
  16. package/dist/core/json.js.map +1 -1
  17. package/dist/core/nvidia.js +9 -2
  18. package/dist/core/nvidia.js.map +1 -1
  19. package/dist/core/ollama.js +8 -1
  20. package/dist/core/ollama.js.map +1 -1
  21. package/dist/core/openrouter.js +13 -8
  22. package/dist/core/openrouter.js.map +1 -1
  23. package/dist/core/reasoning.d.ts +12 -0
  24. package/dist/core/reasoning.js +108 -0
  25. package/dist/core/reasoning.js.map +1 -0
  26. package/dist/core/session.d.ts +31 -0
  27. package/dist/core/session.js +154 -0
  28. package/dist/core/session.js.map +1 -0
  29. package/dist/core/types.d.ts +103 -2
  30. package/dist/core/workspace.d.ts +17 -1
  31. package/dist/core/workspace.js +495 -13
  32. package/dist/core/workspace.js.map +1 -1
  33. package/dist/tui/App.js +291 -88
  34. package/dist/tui/App.js.map +1 -1
  35. package/dist/tui/commands.js +37 -2
  36. package/dist/tui/commands.js.map +1 -1
  37. package/dist/tui/components/Header.d.ts +2 -2
  38. package/dist/tui/components/Header.js +17 -54
  39. package/dist/tui/components/Header.js.map +1 -1
  40. package/dist/tui/components/OnboardingPanel.d.ts +5 -0
  41. package/dist/tui/components/OnboardingPanel.js +11 -13
  42. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  43. package/dist/tui/components/Sidebar.d.ts +6 -1
  44. package/dist/tui/components/Sidebar.js +15 -6
  45. package/dist/tui/components/Sidebar.js.map +1 -1
  46. package/dist/tui/components/Transcript.js +57 -8
  47. package/dist/tui/components/Transcript.js.map +1 -1
  48. package/dist/tui/hosts.js +7 -1
  49. package/dist/tui/hosts.js.map +1 -1
  50. package/dist/tui/modelSelection.d.ts +1 -0
  51. package/dist/tui/modelSelection.js +29 -0
  52. package/dist/tui/modelSelection.js.map +1 -0
  53. package/dist/tui/types.d.ts +12 -2
  54. package/dist/tui/types.js.map +1 -1
  55. package/docs/releases/v0.1.0.md +26 -0
  56. package/docs/releases/v0.2.0.md +21 -0
  57. package/docs/releases/v0.2.1.md +26 -0
  58. package/docs/releases/v0.3.0.md +26 -0
  59. package/docs/showcase/patchpilot-showcase.svg +83 -38
  60. package/package.json +5 -2
  61. package/dist/tui/inputRouting.d.ts +0 -8
  62. package/dist/tui/inputRouting.js +0 -94
  63. package/dist/tui/inputRouting.js.map +0 -1
@@ -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
- return denied("write_file denied. Restart with --apply to allow workspace writes.");
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
- return denied("run_shell denied. Restart with --allow-shell to allow commands.");
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
- resolve(null);
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("~")) {