@jx-grxf/patchpilot 1.0.0 → 1.2.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 (154) hide show
  1. package/README.md +51 -16
  2. package/dist/cli.js +46 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/agent.d.ts +44 -1
  5. package/dist/core/agent.js +617 -70
  6. package/dist/core/agent.js.map +1 -1
  7. package/dist/core/clipboard.d.ts +14 -0
  8. package/dist/core/clipboard.js +134 -0
  9. package/dist/core/clipboard.js.map +1 -0
  10. package/dist/core/codex.d.ts +8 -0
  11. package/dist/core/codex.js +28 -2
  12. package/dist/core/codex.js.map +1 -1
  13. package/dist/core/compaction.d.ts +23 -0
  14. package/dist/core/compaction.js +145 -0
  15. package/dist/core/compaction.js.map +1 -0
  16. package/dist/core/contextFormat.d.ts +21 -0
  17. package/dist/core/contextFormat.js +87 -0
  18. package/dist/core/contextFormat.js.map +1 -0
  19. package/dist/core/contextItem.d.ts +41 -0
  20. package/dist/core/contextItem.js +93 -0
  21. package/dist/core/contextItem.js.map +1 -0
  22. package/dist/core/contextStore.d.ts +48 -0
  23. package/dist/core/contextStore.js +306 -0
  24. package/dist/core/contextStore.js.map +1 -0
  25. package/dist/core/doctor.js +9 -8
  26. package/dist/core/doctor.js.map +1 -1
  27. package/dist/core/gemini.js +10 -4
  28. package/dist/core/gemini.js.map +1 -1
  29. package/dist/core/geminiWrapper.d.ts +43 -2
  30. package/dist/core/geminiWrapper.js +582 -42
  31. package/dist/core/geminiWrapper.js.map +1 -1
  32. package/dist/core/http.js +70 -6
  33. package/dist/core/http.js.map +1 -1
  34. package/dist/core/json.d.ts +1 -1
  35. package/dist/core/json.js +18 -20
  36. package/dist/core/json.js.map +1 -1
  37. package/dist/core/nvidia.d.ts +1 -1
  38. package/dist/core/nvidia.js +13 -4
  39. package/dist/core/nvidia.js.map +1 -1
  40. package/dist/core/ollama.js +13 -3
  41. package/dist/core/ollama.js.map +1 -1
  42. package/dist/core/openrouter.js +15 -6
  43. package/dist/core/openrouter.js.map +1 -1
  44. package/dist/core/reasoning.js +3 -0
  45. package/dist/core/reasoning.js.map +1 -1
  46. package/dist/core/session.js +9 -3
  47. package/dist/core/session.js.map +1 -1
  48. package/dist/core/tokenAccounting.d.ts +4 -0
  49. package/dist/core/tokenAccounting.js +75 -13
  50. package/dist/core/tokenAccounting.js.map +1 -1
  51. package/dist/core/types.d.ts +58 -3
  52. package/dist/core/types.js +30 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/core/updateCheck.d.ts +19 -0
  55. package/dist/core/updateCheck.js +103 -0
  56. package/dist/core/updateCheck.js.map +1 -0
  57. package/dist/core/workspace.d.ts +29 -0
  58. package/dist/core/workspace.js +1271 -92
  59. package/dist/core/workspace.js.map +1 -1
  60. package/dist/tui/App.d.ts +1 -0
  61. package/dist/tui/App.js +1346 -112
  62. package/dist/tui/App.js.map +1 -1
  63. package/dist/tui/commands.js +109 -6
  64. package/dist/tui/commands.js.map +1 -1
  65. package/dist/tui/components/ApprovalPanel.js +16 -1
  66. package/dist/tui/components/ApprovalPanel.js.map +1 -1
  67. package/dist/tui/components/CommandSuggestions.js +26 -3
  68. package/dist/tui/components/CommandSuggestions.js.map +1 -1
  69. package/dist/tui/components/Composer.d.ts +3 -0
  70. package/dist/tui/components/Composer.js +57 -5
  71. package/dist/tui/components/Composer.js.map +1 -1
  72. package/dist/tui/components/ExperimentalPanel.d.ts +1 -1
  73. package/dist/tui/components/ExperimentalPanel.js +5 -0
  74. package/dist/tui/components/ExperimentalPanel.js.map +1 -1
  75. package/dist/tui/components/OnboardingPanel.d.ts +12 -0
  76. package/dist/tui/components/OnboardingPanel.js +69 -21
  77. package/dist/tui/components/OnboardingPanel.js.map +1 -1
  78. package/dist/tui/components/StartupBanner.d.ts +4 -0
  79. package/dist/tui/components/StartupBanner.js +9 -0
  80. package/dist/tui/components/StartupBanner.js.map +1 -0
  81. package/dist/tui/components/Transcript.d.ts +7 -0
  82. package/dist/tui/components/Transcript.js +86 -16
  83. package/dist/tui/components/Transcript.js.map +1 -1
  84. package/dist/tui/contextCommands.d.ts +8 -0
  85. package/dist/tui/contextCommands.js +205 -0
  86. package/dist/tui/contextCommands.js.map +1 -0
  87. package/dist/tui/experimental/AnimatedText.d.ts +38 -0
  88. package/dist/tui/experimental/AnimatedText.js +55 -0
  89. package/dist/tui/experimental/AnimatedText.js.map +1 -0
  90. package/dist/tui/experimental/Banner.d.ts +10 -0
  91. package/dist/tui/experimental/Banner.js +33 -0
  92. package/dist/tui/experimental/Banner.js.map +1 -0
  93. package/dist/tui/experimental/CommandPalette.d.ts +11 -0
  94. package/dist/tui/experimental/CommandPalette.js +25 -0
  95. package/dist/tui/experimental/CommandPalette.js.map +1 -0
  96. package/dist/tui/experimental/ExperimentalShell.d.ts +58 -0
  97. package/dist/tui/experimental/ExperimentalShell.js +366 -0
  98. package/dist/tui/experimental/ExperimentalShell.js.map +1 -0
  99. package/dist/tui/experimental/ThemePicker.d.ts +13 -0
  100. package/dist/tui/experimental/ThemePicker.js +12 -0
  101. package/dist/tui/experimental/ThemePicker.js.map +1 -0
  102. package/dist/tui/experimental/attachments.d.ts +35 -0
  103. package/dist/tui/experimental/attachments.js +244 -0
  104. package/dist/tui/experimental/attachments.js.map +1 -0
  105. package/dist/tui/experimental/composer.d.ts +24 -0
  106. package/dist/tui/experimental/composer.js +84 -0
  107. package/dist/tui/experimental/composer.js.map +1 -0
  108. package/dist/tui/experimental/geminiPricing.d.ts +16 -0
  109. package/dist/tui/experimental/geminiPricing.js +39 -0
  110. package/dist/tui/experimental/geminiPricing.js.map +1 -0
  111. package/dist/tui/experimental/layout.d.ts +46 -0
  112. package/dist/tui/experimental/layout.js +112 -0
  113. package/dist/tui/experimental/layout.js.map +1 -0
  114. package/dist/tui/experimental/theme.d.ts +35 -0
  115. package/dist/tui/experimental/theme.js +86 -0
  116. package/dist/tui/experimental/theme.js.map +1 -0
  117. package/dist/tui/experimental/transcriptRows.d.ts +20 -0
  118. package/dist/tui/experimental/transcriptRows.js +169 -0
  119. package/dist/tui/experimental/transcriptRows.js.map +1 -0
  120. package/dist/tui/experimental/ultraModes.d.ts +46 -0
  121. package/dist/tui/experimental/ultraModes.js +95 -0
  122. package/dist/tui/experimental/ultraModes.js.map +1 -0
  123. package/dist/tui/experimental/ultramaxx.d.ts +19 -0
  124. package/dist/tui/experimental/ultramaxx.js +43 -0
  125. package/dist/tui/experimental/ultramaxx.js.map +1 -0
  126. package/dist/tui/format.d.ts +4 -2
  127. package/dist/tui/format.js +14 -0
  128. package/dist/tui/format.js.map +1 -1
  129. package/dist/tui/hosts.js +7 -1
  130. package/dist/tui/hosts.js.map +1 -1
  131. package/dist/tui/layout.d.ts +26 -0
  132. package/dist/tui/layout.js +66 -0
  133. package/dist/tui/layout.js.map +1 -0
  134. package/dist/tui/modelSelection.d.ts +1 -1
  135. package/dist/tui/modelSelection.js +8 -6
  136. package/dist/tui/modelSelection.js.map +1 -1
  137. package/dist/tui/modes.d.ts +7 -0
  138. package/dist/tui/modes.js +12 -0
  139. package/dist/tui/modes.js.map +1 -1
  140. package/dist/tui/onboardingPreferences.d.ts +37 -0
  141. package/dist/tui/onboardingPreferences.js +118 -0
  142. package/dist/tui/onboardingPreferences.js.map +1 -0
  143. package/dist/tui/runStatus.d.ts +50 -0
  144. package/dist/tui/runStatus.js +164 -0
  145. package/dist/tui/runStatus.js.map +1 -0
  146. package/dist/tui/types.d.ts +8 -0
  147. package/dist/tui/types.js.map +1 -1
  148. package/docs/architecture.md +115 -0
  149. package/docs/gemini-wrapper.md +23 -0
  150. package/docs/product-context.md +43 -0
  151. package/docs/releases/v1.0.1.md +25 -0
  152. package/docs/releases/v1.1.0.md +30 -0
  153. package/docs/releases/v1.2.0.md +28 -0
  154. package/package.json +4 -2
@@ -1,10 +1,11 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { constants } from "node:fs";
3
- import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
- import { platform } from "node:os";
4
+ import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, platform, tmpdir } from "node:os";
5
6
  import path from "node:path";
6
7
  import { promisify } from "node:util";
7
- import { inflateRawSync } from "node:zlib";
8
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
8
9
  import { MemoryStore } from "./memory.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  const ignoredDirectories = new Set([
@@ -28,6 +29,7 @@ const textFileExtensions = new Set([
28
29
  ".h",
29
30
  ".hpp",
30
31
  ".html",
32
+ ".svg",
31
33
  ".js",
32
34
  ".json",
33
35
  ".jsx",
@@ -39,6 +41,7 @@ const textFileExtensions = new Set([
39
41
  ".ts",
40
42
  ".tsx",
41
43
  ".txt",
44
+ ".jsonl",
42
45
  ".java",
43
46
  ".kt",
44
47
  ".go",
@@ -80,6 +83,14 @@ const blockedPathPatterns = [
80
83
  /(^|\/)(default|profile \d+|profiles?)\/(cookies|network\/cookies|login data|web data)$/i
81
84
  ];
82
85
  export const toolSpecs = {
86
+ update_todo: {
87
+ name: "update_todo",
88
+ description: "Update the agent's visible task checklist.",
89
+ risk: "low",
90
+ sideEffects: "none",
91
+ permission: "none",
92
+ category: "state"
93
+ },
83
94
  list_files: {
84
95
  name: "list_files",
85
96
  description: "List workspace files under a directory.",
@@ -88,6 +99,14 @@ export const toolSpecs = {
88
99
  permission: "none",
89
100
  category: "read"
90
101
  },
102
+ find_files: {
103
+ name: "find_files",
104
+ description: "Find workspace files by path/name substring.",
105
+ risk: "low",
106
+ sideEffects: "none",
107
+ permission: "none",
108
+ category: "search"
109
+ },
91
110
  read_file: {
92
111
  name: "read_file",
93
112
  description: "Read a complete text/code file.",
@@ -160,6 +179,22 @@ export const toolSpecs = {
160
179
  permission: "none",
161
180
  category: "git"
162
181
  },
182
+ git_log: {
183
+ name: "git_log",
184
+ description: "Read recent Git commits.",
185
+ risk: "low",
186
+ sideEffects: "none",
187
+ permission: "none",
188
+ category: "git"
189
+ },
190
+ git_show: {
191
+ name: "git_show",
192
+ description: "Read a compact Git commit or revision summary.",
193
+ risk: "low",
194
+ sideEffects: "none",
195
+ permission: "none",
196
+ category: "git"
197
+ },
163
198
  list_changed_files: {
164
199
  name: "list_changed_files",
165
200
  description: "List changed files from Git porcelain status.",
@@ -176,6 +211,30 @@ export const toolSpecs = {
176
211
  permission: "none",
177
212
  category: "read"
178
213
  },
214
+ repo_overview: {
215
+ name: "repo_overview",
216
+ description: "Read a compact repository overview: package metadata, top-level files, and Git state.",
217
+ risk: "low",
218
+ sideEffects: "none",
219
+ permission: "none",
220
+ category: "read"
221
+ },
222
+ test_list: {
223
+ name: "test_list",
224
+ description: "List likely tests and test scripts without running them.",
225
+ risk: "low",
226
+ sideEffects: "none",
227
+ permission: "none",
228
+ category: "test"
229
+ },
230
+ dependency_tree: {
231
+ name: "dependency_tree",
232
+ description: "Read top-level package dependencies from package.json.",
233
+ risk: "low",
234
+ sideEffects: "none",
235
+ permission: "none",
236
+ category: "read"
237
+ },
179
238
  write_file: {
180
239
  name: "write_file",
181
240
  description: "Write a full file in the workspace.",
@@ -184,6 +243,30 @@ export const toolSpecs = {
184
243
  permission: "write",
185
244
  category: "write"
186
245
  },
246
+ edit_file: {
247
+ name: "edit_file",
248
+ description: "Edit an existing text file by unique find/replace or by replacing a bounded line range with an optional expected-content guard.",
249
+ risk: "high",
250
+ sideEffects: "write",
251
+ permission: "write",
252
+ category: "write"
253
+ },
254
+ create_pdf: {
255
+ name: "create_pdf",
256
+ description: "Create a simple text PDF file in the workspace.",
257
+ risk: "high",
258
+ sideEffects: "write",
259
+ permission: "write",
260
+ category: "write"
261
+ },
262
+ create_docx: {
263
+ name: "create_docx",
264
+ description: "Create a simple text DOCX file in the workspace.",
265
+ risk: "high",
266
+ sideEffects: "write",
267
+ permission: "write",
268
+ category: "write"
269
+ },
187
270
  apply_patch: {
188
271
  name: "apply_patch",
189
272
  description: "Apply a unified Git patch inside the workspace.",
@@ -225,8 +308,10 @@ export class WorkspaceTools {
225
308
  rootRealPath;
226
309
  allowWrite;
227
310
  allowShell;
311
+ allowShellMetacharacters;
228
312
  allowExternalFileAnalysis;
229
313
  memoryEnabled;
314
+ documentAnalyzer;
230
315
  timeoutMs;
231
316
  signal;
232
317
  approvalHandler;
@@ -236,7 +321,9 @@ export class WorkspaceTools {
236
321
  this.rootRealPath = realpath(this.root).catch(() => this.root);
237
322
  this.allowWrite = options.allowWrite;
238
323
  this.allowShell = options.allowShell;
324
+ this.allowShellMetacharacters = Boolean(options.allowShellMetacharacters);
239
325
  this.allowExternalFileAnalysis = Boolean(options.allowExternalFileAnalysis);
326
+ this.documentAnalyzer = options.documentAnalyzer;
240
327
  this.memoryEnabled = Boolean(options.memoryEnabled);
241
328
  this.timeoutMs = options.timeoutMs ?? 60_000;
242
329
  this.signal = options.signal;
@@ -245,8 +332,12 @@ export class WorkspaceTools {
245
332
  async execute(call) {
246
333
  try {
247
334
  switch (call.name) {
335
+ case "update_todo":
336
+ return this.updateTodo(call.arguments);
248
337
  case "list_files":
249
338
  return await this.listFiles(readString(call.arguments.path, "."));
339
+ case "find_files":
340
+ return await this.findFiles(readString(call.arguments.query, ""), readNumber(call.arguments.limit, 80));
250
341
  case "read_file":
251
342
  return await this.readFile(readString(call.arguments.path, ""));
252
343
  case "read_range":
@@ -256,7 +347,7 @@ export class WorkspaceTools {
256
347
  case "search_text":
257
348
  return await this.searchText(readString(call.arguments.query, ""));
258
349
  case "inspect_document":
259
- return await this.inspectDocument(readString(call.arguments.path, ""));
350
+ return await this.inspectDocument(readString(call.arguments.path, ""), readString(call.arguments.mode, "auto"));
260
351
  case "memory_remember":
261
352
  return await this.memoryRemember(readString(call.arguments.content, ""), readStringArray(call.arguments.tags));
262
353
  case "memory_search":
@@ -265,12 +356,28 @@ export class WorkspaceTools {
265
356
  return await this.gitStatus();
266
357
  case "git_diff":
267
358
  return await this.gitDiff(readString(call.arguments.path, ""));
359
+ case "git_log":
360
+ return await this.gitLog(readNumber(call.arguments.limit, 8));
361
+ case "git_show":
362
+ return await this.gitShow(readString(call.arguments.revision, "HEAD"), readString(call.arguments.path, ""));
268
363
  case "list_changed_files":
269
364
  return await this.listChangedFiles();
270
365
  case "list_scripts":
271
366
  return await this.listScripts();
367
+ case "repo_overview":
368
+ return await this.repoOverview();
369
+ case "test_list":
370
+ return await this.testList();
371
+ case "dependency_tree":
372
+ return await this.dependencyTree();
272
373
  case "write_file":
273
374
  return await this.writeFile(readString(call.arguments.path, ""), readString(call.arguments.content, ""));
375
+ case "edit_file":
376
+ return await this.editFile(readString(call.arguments.path, ""), readString(call.arguments.find, ""), readString(call.arguments.replace, ""), readNumber(call.arguments.startLine, 0), readNumber(call.arguments.endLine, 0), readString(call.arguments.replacement, ""), readOptionalString(call.arguments.expected));
377
+ case "create_pdf":
378
+ return await this.createPdf(readString(call.arguments.path, ""), readString(call.arguments.content, ""), readString(call.arguments.title, ""));
379
+ case "create_docx":
380
+ return await this.createDocx(readString(call.arguments.path, ""), readString(call.arguments.content, ""), readString(call.arguments.title, ""));
274
381
  case "apply_patch":
275
382
  return await this.applyPatch(readString(call.arguments.patch, ""));
276
383
  case "run_script":
@@ -287,6 +394,18 @@ export class WorkspaceTools {
287
394
  return denied(error instanceof Error ? error.message : String(error));
288
395
  }
289
396
  }
397
+ updateTodo(argumentsValue) {
398
+ return {
399
+ ok: true,
400
+ summary: "updated visible todo list",
401
+ content: JSON.stringify({ items: Array.isArray(argumentsValue.items) ? argumentsValue.items : [] }),
402
+ tool: "update_todo",
403
+ category: toolSpecs.update_todo.category,
404
+ metadata: {
405
+ items: Array.isArray(argumentsValue.items) ? argumentsValue.items : []
406
+ }
407
+ };
408
+ }
290
409
  resolveInsideWorkspace(requestedPath) {
291
410
  const workspaceRelativePath = this.normalizeWorkspaceRelativePath(requestedPath);
292
411
  const absolutePath = path.resolve(this.root, workspaceRelativePath);
@@ -331,6 +450,35 @@ export class WorkspaceTools {
331
450
  category: toolSpecs.list_files.category
332
451
  };
333
452
  }
453
+ async findFiles(query, limit) {
454
+ const normalizedQuery = query.trim().replaceAll("\\", "/").toLowerCase();
455
+ if (!normalizedQuery) {
456
+ return denied("find_files requires a non-empty query.", "find_files");
457
+ }
458
+ if (isPlaceholderPath(normalizedQuery)) {
459
+ return denied(`find_files denied placeholder query: ${query}`, "find_files");
460
+ }
461
+ if (isSensitivePath(normalizedQuery)) {
462
+ return denied(`find_files denied sensitive query: ${query}`, "find_files");
463
+ }
464
+ const normalizedLimit = Math.max(1, Math.min(200, Math.floor(limit || 80)));
465
+ const files = await walkFiles(this.root, this.root, await this.rootRealPath, 10, 1200);
466
+ const matches = files
467
+ .filter((filePath) => filePath.toLowerCase().includes(normalizedQuery))
468
+ .slice(0, normalizedLimit);
469
+ return {
470
+ ok: true,
471
+ summary: `found ${matches.length} file match${matches.length === 1 ? "" : "es"}`,
472
+ content: matches.join("\n") || "No matching files.",
473
+ tool: "find_files",
474
+ category: toolSpecs.find_files.category,
475
+ metadata: {
476
+ query: normalizedQuery,
477
+ limit: normalizedLimit,
478
+ truncated: matches.length >= normalizedLimit
479
+ }
480
+ };
481
+ }
334
482
  async readFile(requestedPath) {
335
483
  if (!requestedPath) {
336
484
  return denied("read_file requires a path.");
@@ -351,10 +499,11 @@ export class WorkspaceTools {
351
499
  const clippedContent = clip(content, 20_000);
352
500
  return {
353
501
  ok: true,
354
- summary: `read ${path.relative(this.root, absolutePath)}`,
502
+ summary: `read ${normalizeRelative(this.root, absolutePath)}`,
355
503
  content: clippedContent,
356
504
  tool: "read_file",
357
- category: toolSpecs.read_file.category
505
+ category: toolSpecs.read_file.category,
506
+ metadata: textContentMetadata(content)
358
507
  };
359
508
  }
360
509
  async readRange(requestedPath, startLine, endLine) {
@@ -379,12 +528,12 @@ export class WorkspaceTools {
379
528
  const numberedLines = selectedLines.map((line, index) => `${startLine + index}: ${line}`).join("\n");
380
529
  return {
381
530
  ok: true,
382
- summary: `read ${path.relative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
531
+ summary: `read ${normalizeRelative(this.root, absolutePath)}:${startLine}-${Math.min(endLine, lines.length)}`,
383
532
  content: clip(numberedLines || "No lines in range.", 20_000),
384
533
  tool: "read_range",
385
534
  category: toolSpecs.read_range.category,
386
535
  metadata: {
387
- path: path.relative(this.root, absolutePath),
536
+ path: normalizeRelative(this.root, absolutePath),
388
537
  startLine,
389
538
  endLine: Math.min(endLine, lines.length)
390
539
  }
@@ -399,7 +548,7 @@ export class WorkspaceTools {
399
548
  }
400
549
  const absolutePath = await this.resolveReadPath(requestedPath);
401
550
  const fileStat = await stat(absolutePath);
402
- const relativePath = path.relative(this.root, absolutePath);
551
+ const relativePath = normalizeRelative(this.root, absolutePath);
403
552
  return {
404
553
  ok: true,
405
554
  summary: `inspected ${relativePath}`,
@@ -418,7 +567,7 @@ export class WorkspaceTools {
418
567
  }
419
568
  };
420
569
  }
421
- async inspectDocument(requestedPath) {
570
+ async inspectDocument(requestedPath, mode) {
422
571
  if (!requestedPath) {
423
572
  return denied("inspect_document requires a path.");
424
573
  }
@@ -441,28 +590,84 @@ export class WorkspaceTools {
441
590
  if (isLikelyTextFile(absolutePath)) {
442
591
  return await this.readTextDocument(absolutePath);
443
592
  }
593
+ const normalizedMode = normalizeDocumentInspectionMode(mode);
594
+ const wantsLocalOnly = normalizedMode === "local" || normalizedMode === "ocr";
444
595
  if (extension === ".pdf") {
445
- return await extractPdfText(absolutePath, this.timeoutMs, this.signal);
596
+ const pdfFallback = await extractPdfText(absolutePath, this.timeoutMs, this.signal);
597
+ if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(pdfFallback)) {
598
+ return pdfFallback;
599
+ }
600
+ const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this PDF for PatchPilot. Extract readable text, describe structure, and note important visual or scanned content.");
601
+ if (providerResult.ok) {
602
+ return providerResult;
603
+ }
604
+ return mergeFallbackDocumentResult(providerResult, pdfFallback);
446
605
  }
447
606
  if (extension === ".docx") {
448
- return await extractDocxText(absolutePath);
607
+ const docxFallback = await extractDocxText(absolutePath);
608
+ if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(docxFallback)) {
609
+ return docxFallback;
610
+ }
611
+ const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this DOCX for PatchPilot. Extract the relevant text, headings, and document structure.");
612
+ if (providerResult.ok) {
613
+ return providerResult;
614
+ }
615
+ return mergeFallbackDocumentResult(providerResult, docxFallback);
616
+ }
617
+ if (extension === ".doc") {
618
+ const docFallback = await extractLegacyDocText(absolutePath, this.timeoutMs, this.signal);
619
+ if (wantsLocalOnly || !this.documentAnalyzer || hasUsefulExtractedText(docFallback)) {
620
+ return docFallback;
621
+ }
622
+ const providerResult = await this.analyzeDocumentWithProvider(absolutePath, "Analyze this Word document for PatchPilot. Extract the relevant text, headings, and document structure.");
623
+ if (providerResult.ok) {
624
+ return providerResult;
625
+ }
626
+ return mergeFallbackDocumentResult(providerResult, docFallback);
449
627
  }
450
628
  if (isImageFile(absolutePath)) {
451
- return await inspectImageFile(absolutePath);
629
+ return await inspectImageFile(absolutePath, wantsLocalOnly ? undefined : this.documentAnalyzer, this.signal, normalizedMode, this.providerAnalysisTimeoutMs());
452
630
  }
453
631
  return denied(`inspect_document does not support ${extension || "this file type"} yet.`);
454
632
  }
455
633
  async readTextDocument(absolutePath) {
456
634
  const content = await readFile(absolutePath, "utf8");
457
- const relativePath = path.relative(this.root, absolutePath);
635
+ const rawRelativePath = path.relative(this.root, absolutePath);
636
+ const relativePath = normalizeRelative(this.root, absolutePath);
458
637
  return {
459
638
  ok: true,
460
- summary: `inspected ${relativePath.startsWith("..") || path.isAbsolute(relativePath) ? absolutePath : relativePath}`,
639
+ summary: `inspected ${rawRelativePath.startsWith("..") || path.isAbsolute(rawRelativePath) ? absolutePath : relativePath}`,
461
640
  content: clip(content, 20_000),
462
641
  tool: "inspect_document",
463
- category: toolSpecs.inspect_document.category
642
+ category: toolSpecs.inspect_document.category,
643
+ metadata: textContentMetadata(content)
464
644
  };
465
645
  }
646
+ async analyzeDocumentWithProvider(absolutePath, prompt) {
647
+ if (!this.documentAnalyzer) {
648
+ return denied("inspect_document has no provider document analyzer configured.", "inspect_document");
649
+ }
650
+ try {
651
+ const analysis = await runDocumentAnalyzer(this.documentAnalyzer, {
652
+ path: absolutePath,
653
+ prompt,
654
+ signal: this.signal
655
+ }, this.providerAnalysisTimeoutMs());
656
+ return {
657
+ ok: true,
658
+ summary: `analyzed ${path.basename(absolutePath)} with provider file input`,
659
+ content: clip(analysis, 20_000),
660
+ tool: "inspect_document",
661
+ category: toolSpecs.inspect_document.category
662
+ };
663
+ }
664
+ catch (error) {
665
+ return denied(`provider file analysis failed for ${path.basename(absolutePath)}: ${error instanceof Error ? error.message : String(error)}`, "inspect_document");
666
+ }
667
+ }
668
+ providerAnalysisTimeoutMs() {
669
+ return Math.min(this.timeoutMs, 90_000);
670
+ }
466
671
  async memoryRemember(content, tags) {
467
672
  if (!this.memoryEnabled) {
468
673
  return denied("memory_remember requires /experimental memory.", "memory_remember");
@@ -552,25 +757,170 @@ export class WorkspaceTools {
552
757
  if (isSensitivePath(requestedPath)) {
553
758
  return denied(`write_file denied sensitive path: ${requestedPath}`);
554
759
  }
760
+ const absolutePath = await this.resolveWritePath(requestedPath);
761
+ const normalized = normalizePossiblyEscapedFileContent(content, absolutePath);
555
762
  if (!this.allowWrite) {
556
763
  const approval = await this.requestApproval("write_file", "write", {
557
764
  path: requestedPath,
558
- contentLength: content.length
559
- }, `Write ${requestedPath} (${content.length} characters).`);
765
+ contentLength: normalized.content.length
766
+ }, `Write ${requestedPath} (${normalized.content.length} characters).`);
560
767
  if (approval.decision === "deny") {
561
768
  return denied("write_file denied by permission policy. Restart with --apply or approve the request in build mode.", "write_file", approval);
562
769
  }
563
770
  }
564
- const absolutePath = await this.resolveWritePath(requestedPath);
565
771
  await mkdir(path.dirname(absolutePath), { recursive: true });
566
- await writeFile(absolutePath, content, "utf8");
772
+ await writeFile(absolutePath, normalized.content, "utf8");
567
773
  return {
568
774
  ok: true,
569
- summary: `wrote ${path.relative(this.root, absolutePath)}`,
570
- content: `Wrote ${content.length} characters.`,
775
+ summary: `wrote ${normalizeRelative(this.root, absolutePath)}`,
776
+ content: `Wrote ${normalized.content.length} characters.${normalized.normalized ? " Normalized escaped newlines before writing." : ""}`,
571
777
  tool: "write_file",
572
778
  category: toolSpecs.write_file.category,
573
- preview: `Write ${path.relative(this.root, absolutePath)}`
779
+ preview: `Write ${normalizeRelative(this.root, absolutePath)}`,
780
+ metadata: {
781
+ normalizedEscapedContent: normalized.normalized
782
+ }
783
+ };
784
+ }
785
+ async createPdf(requestedPath, content, title) {
786
+ if (!requestedPath) {
787
+ return denied("create_pdf requires a path.", "create_pdf");
788
+ }
789
+ const targetPath = requestedPath.toLowerCase().endsWith(".pdf") ? requestedPath : `${requestedPath}.pdf`;
790
+ const approval = await this.requestWriteApproval("create_pdf", targetPath, content.length, "Create PDF");
791
+ if (approval) {
792
+ return approval;
793
+ }
794
+ const absolutePath = await this.resolveWritePath(targetPath);
795
+ await mkdir(path.dirname(absolutePath), { recursive: true });
796
+ const pdf = createSimplePdf(content, title || path.basename(targetPath, ".pdf"));
797
+ await writeFile(absolutePath, pdf);
798
+ return {
799
+ ok: true,
800
+ summary: `created PDF ${normalizeRelative(this.root, absolutePath)}`,
801
+ content: `Created ${pdf.length} byte PDF from ${content.length} characters.`,
802
+ tool: "create_pdf",
803
+ category: toolSpecs.create_pdf.category,
804
+ preview: `Create PDF ${normalizeRelative(this.root, absolutePath)}`
805
+ };
806
+ }
807
+ async createDocx(requestedPath, content, title) {
808
+ if (!requestedPath) {
809
+ return denied("create_docx requires a path.", "create_docx");
810
+ }
811
+ const targetPath = requestedPath.toLowerCase().endsWith(".docx") ? requestedPath : `${requestedPath}.docx`;
812
+ const approval = await this.requestWriteApproval("create_docx", targetPath, content.length, "Create DOCX");
813
+ if (approval) {
814
+ return approval;
815
+ }
816
+ const absolutePath = await this.resolveWritePath(targetPath);
817
+ await mkdir(path.dirname(absolutePath), { recursive: true });
818
+ const docx = createSimpleDocx(content, title);
819
+ await writeFile(absolutePath, docx);
820
+ return {
821
+ ok: true,
822
+ summary: `created DOCX ${normalizeRelative(this.root, absolutePath)}`,
823
+ content: `Created ${docx.length} byte DOCX from ${content.length} characters.`,
824
+ tool: "create_docx",
825
+ category: toolSpecs.create_docx.category,
826
+ preview: `Create DOCX ${normalizeRelative(this.root, absolutePath)}`
827
+ };
828
+ }
829
+ async requestWriteApproval(tool, requestedPath, contentLength, action) {
830
+ if (isPlaceholderPath(requestedPath)) {
831
+ return denied(`${tool} denied placeholder path: ${requestedPath}`, tool);
832
+ }
833
+ if (isSensitivePath(requestedPath)) {
834
+ return denied(`${tool} denied sensitive path: ${requestedPath}`, tool);
835
+ }
836
+ if (this.allowWrite) {
837
+ return null;
838
+ }
839
+ const approval = await this.requestApproval(tool, "write", {
840
+ path: requestedPath,
841
+ contentLength
842
+ }, `${action} ${requestedPath} (${contentLength} characters).`);
843
+ if (approval.decision === "deny") {
844
+ return denied(`${tool} denied by permission policy. Restart with --apply or approve the request in build mode.`, tool, approval);
845
+ }
846
+ return null;
847
+ }
848
+ async editFile(requestedPath, findText, replaceText, startLine, endLine, replacementText, expectedText) {
849
+ if (!requestedPath) {
850
+ return denied("edit_file requires a path.", "edit_file");
851
+ }
852
+ if (isPlaceholderPath(requestedPath)) {
853
+ return denied(`edit_file denied placeholder path: ${requestedPath}`, "edit_file");
854
+ }
855
+ if (isSensitivePath(requestedPath)) {
856
+ return denied(`edit_file denied sensitive path: ${requestedPath}`, "edit_file");
857
+ }
858
+ const usesLineRange = startLine > 0 || endLine > 0;
859
+ const usesFindReplace = findText.length > 0;
860
+ if (usesLineRange === usesFindReplace) {
861
+ return denied("edit_file requires either find/replace or startLine/endLine/replacement.", "edit_file");
862
+ }
863
+ if (usesLineRange && (startLine < 1 || endLine < startLine)) {
864
+ return denied("edit_file requires 1-based startLine/endLine values.", "edit_file");
865
+ }
866
+ const absolutePath = await this.resolveWritePath(requestedPath);
867
+ if (!isLikelyTextFile(absolutePath)) {
868
+ return denied(`edit_file supports text/code files. Use write_file only when replacing the full ${path.extname(absolutePath) || "file"} file is intentional.`, "edit_file");
869
+ }
870
+ const originalContent = await readFile(absolutePath, "utf8").catch((error) => {
871
+ throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
872
+ });
873
+ const normalizedReplaceText = normalizePossiblyEscapedFileContent(replaceText, absolutePath).content;
874
+ const normalizedReplacementText = normalizePossiblyEscapedFileContent(replacementText, absolutePath).content;
875
+ const normalizedExpectedText = expectedText === undefined ? undefined : normalizePossiblyEscapedFileContent(expectedText, absolutePath).content;
876
+ let nextContent = originalContent;
877
+ let editSummary = "";
878
+ if (usesFindReplace) {
879
+ const matches = countOccurrences(originalContent, findText);
880
+ if (matches !== 1) {
881
+ return denied(`edit_file find text must match exactly once; found ${matches} matches.`, "edit_file");
882
+ }
883
+ nextContent = originalContent.replace(findText, normalizedReplaceText);
884
+ editSummary = `replaced 1 match in ${normalizeRelative(this.root, absolutePath)}`;
885
+ }
886
+ else {
887
+ const lines = originalContent.split(/\r?\n/);
888
+ if (endLine > lines.length) {
889
+ return denied(`edit_file line range exceeds file length (${lines.length} lines).`, "edit_file");
890
+ }
891
+ const replacementLines = normalizedReplacementText.split(/\r?\n/);
892
+ const currentRange = lines.slice(startLine - 1, endLine).join("\n");
893
+ if (normalizedExpectedText !== undefined && currentRange !== normalizedExpectedText) {
894
+ return denied("edit_file expected content did not match the current line range.", "edit_file");
895
+ }
896
+ lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
897
+ nextContent = lines.join("\n");
898
+ editSummary = `replaced lines ${startLine}-${endLine} in ${normalizeRelative(this.root, absolutePath)}`;
899
+ }
900
+ if (nextContent === originalContent) {
901
+ return denied("edit_file produced no changes.", "edit_file");
902
+ }
903
+ if (!this.allowWrite) {
904
+ const approval = await this.requestApproval("edit_file", "write", {
905
+ path: requestedPath,
906
+ startLine: usesLineRange ? startLine : undefined,
907
+ endLine: usesLineRange ? endLine : undefined,
908
+ findLength: usesFindReplace ? findText.length : undefined,
909
+ expectedLength: normalizedExpectedText?.length,
910
+ replacementLength: usesLineRange ? normalizedReplacementText.length : normalizedReplaceText.length
911
+ }, `Edit ${requestedPath}: ${editSummary}`);
912
+ if (approval.decision === "deny") {
913
+ return denied("edit_file denied by permission policy. Restart with --apply or approve the request in build mode.", "edit_file", approval);
914
+ }
915
+ }
916
+ await writeFile(absolutePath, nextContent, "utf8");
917
+ return {
918
+ ok: true,
919
+ summary: editSummary,
920
+ content: `Edited ${normalizeRelative(this.root, absolutePath)}.`,
921
+ tool: "edit_file",
922
+ category: toolSpecs.edit_file.category,
923
+ preview: `Edit ${normalizeRelative(this.root, absolutePath)}`
574
924
  };
575
925
  }
576
926
  async gitStatus() {
@@ -610,6 +960,48 @@ export class WorkspaceTools {
610
960
  category: toolSpecs.git_diff.category
611
961
  };
612
962
  }
963
+ async gitLog(limit) {
964
+ const normalizedLimit = Math.max(1, Math.min(50, Math.floor(limit || 8)));
965
+ const { stdout } = await execFileAsync("git", ["log", "--oneline", "--decorate", `--max-count=${normalizedLimit}`], {
966
+ cwd: this.root,
967
+ timeout: Math.min(this.timeoutMs, 8000),
968
+ maxBuffer: 200_000,
969
+ signal: this.signal,
970
+ windowsHide: true
971
+ });
972
+ return {
973
+ ok: true,
974
+ summary: `read ${normalizedLimit} git commit${normalizedLimit === 1 ? "" : "s"}`,
975
+ content: stdout.trim() || "No commits found.",
976
+ tool: "git_log",
977
+ category: toolSpecs.git_log.category
978
+ };
979
+ }
980
+ async gitShow(revision, requestedPath) {
981
+ const normalizedRevision = revision.trim() || "HEAD";
982
+ if (!/^[A-Za-z0-9_./:@{}^~+-]+$/.test(normalizedRevision)) {
983
+ return denied("git_show revision contains unsupported characters.", "git_show");
984
+ }
985
+ const args = ["show", "--stat", "--oneline", "--decorate", "--no-ext-diff", normalizedRevision, "--"];
986
+ if (requestedPath.trim()) {
987
+ const absolutePath = this.resolveInsideWorkspace(requestedPath);
988
+ args.push(path.relative(this.root, absolutePath));
989
+ }
990
+ const { stdout } = await execFileAsync("git", args, {
991
+ cwd: this.root,
992
+ timeout: Math.min(this.timeoutMs, 8000),
993
+ maxBuffer: 500_000,
994
+ signal: this.signal,
995
+ windowsHide: true
996
+ });
997
+ return {
998
+ ok: true,
999
+ summary: `read git revision ${normalizedRevision}`,
1000
+ content: clip(stdout.trim() || "No revision output.", 20_000),
1001
+ tool: "git_show",
1002
+ category: toolSpecs.git_show.category
1003
+ };
1004
+ }
613
1005
  async listChangedFiles() {
614
1006
  const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
615
1007
  cwd: this.root,
@@ -644,13 +1036,95 @@ export class WorkspaceTools {
644
1036
  category: toolSpecs.list_scripts.category
645
1037
  };
646
1038
  }
1039
+ async repoOverview() {
1040
+ const packageJson = await this.readPackageJsonObject().catch(() => ({}));
1041
+ const rootRealPath = await this.rootRealPath;
1042
+ const files = await walkFiles(this.root, this.root, rootRealPath, 1, 80).catch(() => []);
1043
+ const gitStatus = await execFileAsync("git", ["status", "--short", "--branch"], {
1044
+ cwd: this.root,
1045
+ timeout: Math.min(this.timeoutMs, 5000),
1046
+ maxBuffer: 100_000,
1047
+ signal: this.signal,
1048
+ windowsHide: true
1049
+ }).then((result) => result.stdout.trim()).catch(() => "No git repository detected.");
1050
+ const scripts = Object.keys(readStringRecord(packageJson.scripts)).sort();
1051
+ const dependencies = Object.keys(readStringRecord(packageJson.dependencies)).length;
1052
+ const devDependencies = Object.keys(readStringRecord(packageJson.devDependencies)).length;
1053
+ return {
1054
+ ok: true,
1055
+ summary: "read repository overview",
1056
+ content: [
1057
+ `name: ${typeof packageJson.name === "string" ? packageJson.name : path.basename(this.root)}`,
1058
+ `version: ${typeof packageJson.version === "string" ? packageJson.version : "unknown"}`,
1059
+ `description: ${typeof packageJson.description === "string" ? packageJson.description : "none"}`,
1060
+ `scripts: ${scripts.join(", ") || "none"}`,
1061
+ `dependencies: ${dependencies} runtime, ${devDependencies} dev`,
1062
+ "",
1063
+ "git:",
1064
+ gitStatus || "clean",
1065
+ "",
1066
+ "top-level files:",
1067
+ files.join("\n") || "No files found."
1068
+ ].join("\n"),
1069
+ tool: "repo_overview",
1070
+ category: toolSpecs.repo_overview.category
1071
+ };
1072
+ }
1073
+ async testList() {
1074
+ const packageJson = await this.readPackageJsonObject().catch(() => ({}));
1075
+ const scripts = Object.entries(readStringRecord(packageJson.scripts))
1076
+ .filter(([name, command]) => /test|spec|vitest|jest|playwright|check/i.test(`${name} ${command}`))
1077
+ .sort(([left], [right]) => left.localeCompare(right));
1078
+ const files = await walkFiles(this.root, this.root, await this.rootRealPath, 8, 500).catch(() => []);
1079
+ const testFiles = files.filter((filePath) => /(^|\/)(__tests__|tests?|specs?)\/|[.-](test|spec)\.[cm]?[jt]sx?$|\.test\./i.test(filePath));
1080
+ return {
1081
+ ok: true,
1082
+ summary: `listed ${testFiles.length} likely test file${testFiles.length === 1 ? "" : "s"}`,
1083
+ content: [
1084
+ "test scripts:",
1085
+ scripts.map(([name, command]) => `${name}: ${command}`).join("\n") || "No test-related scripts found.",
1086
+ "",
1087
+ "test files:",
1088
+ testFiles.slice(0, 120).join("\n") || "No likely test files found."
1089
+ ].join("\n"),
1090
+ tool: "test_list",
1091
+ category: toolSpecs.test_list.category
1092
+ };
1093
+ }
1094
+ async dependencyTree() {
1095
+ const packageJson = await this.readPackageJsonObject();
1096
+ const sections = [
1097
+ ["dependencies", readStringRecord(packageJson.dependencies)],
1098
+ ["devDependencies", readStringRecord(packageJson.devDependencies)],
1099
+ ["peerDependencies", readStringRecord(packageJson.peerDependencies)],
1100
+ ["optionalDependencies", readStringRecord(packageJson.optionalDependencies)]
1101
+ ];
1102
+ const content = sections
1103
+ .map(([sectionName, dependencies]) => {
1104
+ const entries = Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right));
1105
+ return [`${sectionName}:`, entries.map(([name, version]) => `- ${name}@${version}`).join("\n") || "- none"].join("\n");
1106
+ })
1107
+ .join("\n\n");
1108
+ return {
1109
+ ok: true,
1110
+ summary: "read dependency tree",
1111
+ content,
1112
+ tool: "dependency_tree",
1113
+ category: toolSpecs.dependency_tree.category
1114
+ };
1115
+ }
647
1116
  async applyPatch(patchContent) {
648
1117
  if (!patchContent.trim()) {
649
1118
  return denied("apply_patch requires a unified patch.", "apply_patch");
650
1119
  }
1120
+ const validationError = await this.validatePatchTargets(patchContent);
1121
+ if (validationError) {
1122
+ return denied(validationError, "apply_patch");
1123
+ }
651
1124
  if (!this.allowWrite) {
652
1125
  const approval = await this.requestApproval("apply_patch", "write", {
653
- patch: clip(patchContent, 1200)
1126
+ patch: clip(patchContent, 1200),
1127
+ patchHash: stableHash(patchContent)
654
1128
  }, previewPatch(patchContent));
655
1129
  if (approval.decision === "deny") {
656
1130
  return denied("apply_patch denied by permission policy.", "apply_patch", approval);
@@ -667,22 +1141,33 @@ export class WorkspaceTools {
667
1141
  };
668
1142
  }
669
1143
  async runScript(scriptName) {
1144
+ return await this.runPackageScript("run_script", scriptName);
1145
+ }
1146
+ async runTests() {
1147
+ return await this.runPackageScript("run_tests", "test");
1148
+ }
1149
+ async runPackageScript(tool, scriptName) {
670
1150
  const normalizedScript = scriptName.trim();
671
1151
  if (!/^[\w:.-]+$/.test(normalizedScript)) {
672
- return denied("run_script requires a package script name such as test or build.", "run_script");
1152
+ return denied(`${tool} requires a package script name such as test or build.`, tool);
673
1153
  }
674
1154
  const scripts = await this.readPackageScripts();
675
1155
  if (!scripts[normalizedScript]) {
676
- return denied(`package script not found: ${normalizedScript}`, "run_script");
1156
+ return denied(`package script not found: ${normalizedScript}`, tool);
677
1157
  }
678
- const scriptCommand = scripts[normalizedScript];
1158
+ const scriptCommands = collectPackageScriptCommands(scripts, normalizedScript);
1159
+ const scriptSafetyError = validatePackageScriptCommands(scriptCommands, this.root);
1160
+ if (scriptSafetyError) {
1161
+ return denied(`${tool} denied package script before approval. ${scriptSafetyError}`, tool);
1162
+ }
1163
+ const approvalCommand = scriptCommands.map((entry) => `${entry.name}: ${entry.command}`).join("\n");
679
1164
  if (!this.allowShell) {
680
- const approval = await this.requestApproval("run_script", "shell", {
1165
+ const approval = await this.requestApproval(tool, "shell", {
681
1166
  script: normalizedScript,
682
- command: scriptCommand
683
- }, previewPackageScript(normalizedScript, scriptCommand, this.root));
1167
+ command: approvalCommand
1168
+ }, previewPackageScriptSequence(normalizedScript, scriptCommands, this.root));
684
1169
  if (approval.decision === "deny") {
685
- return denied("run_script denied by permission policy.", "run_script", approval);
1170
+ return denied(`${tool} denied by permission policy.`, tool, approval);
686
1171
  }
687
1172
  }
688
1173
  const output = await runCommand(`npm run ${normalizedScript}`, this.root, this.timeoutMs, this.signal);
@@ -690,36 +1175,34 @@ export class WorkspaceTools {
690
1175
  ok: output.exitCode === 0,
691
1176
  summary: `npm run ${normalizedScript} exited ${output.exitCode}`,
692
1177
  content: clip(output.output, 20_000),
693
- tool: "run_script",
694
- category: toolSpecs.run_script.category,
695
- preview: previewPackageScript(normalizedScript, scriptCommand, this.root)
696
- };
697
- }
698
- async runTests() {
699
- const scripts = await this.readPackageScripts();
700
- if (!scripts.test) {
701
- return denied("No package test script found.", "run_tests");
702
- }
703
- const result = await this.runScript("test");
704
- return {
705
- ...result,
706
- tool: "run_tests",
707
- category: toolSpecs.run_tests.category,
708
- preview: "npm test"
1178
+ tool,
1179
+ category: toolSpecs[tool].category,
1180
+ preview: previewPackageScriptSequence(normalizedScript, scriptCommands, this.root)
709
1181
  };
710
1182
  }
711
1183
  async runShell(command) {
712
1184
  if (!command.trim()) {
713
1185
  return denied("run_shell requires a command.");
714
1186
  }
715
- const shellSafetyError = validateShellCommand(command, this.root);
716
- if (shellSafetyError) {
717
- return denied(`run_shell denied. ${shellSafetyError}`);
1187
+ const shellSafety = validateShellCommand(command, this.root, {
1188
+ allowMetacharacters: this.allowShellMetacharacters
1189
+ });
1190
+ if (shellSafety.error) {
1191
+ return denied(`run_shell denied. ${shellSafety.error}`);
718
1192
  }
719
- if (!this.allowShell) {
1193
+ const shellPathError = await this.validateShellPathArguments(command);
1194
+ if (shellPathError) {
1195
+ return denied(`run_shell denied. ${shellPathError}`);
1196
+ }
1197
+ if (!this.allowShell || shellSafety.requiresApprovalReason) {
720
1198
  const approval = await this.requestApproval("run_shell", "shell", {
721
- command
722
- }, `Run shell command: ${command}`);
1199
+ command,
1200
+ ...(shellSafety.requiresApprovalReason ? { approvalReason: shellSafety.requiresApprovalReason } : {})
1201
+ }, shellSafety.requiresApprovalReason
1202
+ ? `High-risk shell command (${shellSafety.requiresApprovalReason}): ${command}`
1203
+ : `Run shell command: ${command}`, {
1204
+ bypassable: !shellSafety.requiresApprovalReason
1205
+ });
723
1206
  if (approval.decision === "deny") {
724
1207
  return denied("run_shell denied by permission policy.", "run_shell", approval);
725
1208
  }
@@ -735,11 +1218,14 @@ export class WorkspaceTools {
735
1218
  };
736
1219
  }
737
1220
  async readPackageScripts() {
738
- const packageJsonPath = await this.resolveReadPath("package.json");
739
- const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
1221
+ const packageJson = await this.readPackageJsonObject();
740
1222
  return Object.fromEntries(Object.entries(packageJson.scripts ?? {}).filter((entry) => typeof entry[1] === "string"));
741
1223
  }
742
- async requestApproval(tool, permission, args, preview) {
1224
+ async readPackageJsonObject() {
1225
+ const packageJsonPath = await this.resolveReadPath("package.json");
1226
+ return JSON.parse(await readFile(packageJsonPath, "utf8"));
1227
+ }
1228
+ async requestApproval(tool, permission, args, preview, options = {}) {
743
1229
  const spec = getToolSpec(tool);
744
1230
  const request = {
745
1231
  id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -747,9 +1233,11 @@ export class WorkspaceTools {
747
1233
  permission,
748
1234
  risk: spec.risk,
749
1235
  preview,
750
- arguments: args
1236
+ arguments: args,
1237
+ bypassable: options.bypassable
751
1238
  };
752
- if (this.sessionApprovals.has(permission)) {
1239
+ const approvalKey = approvalScopeKey(tool, permission, args);
1240
+ if (this.sessionApprovals.has(approvalKey)) {
753
1241
  return {
754
1242
  request,
755
1243
  decision: "allow_session"
@@ -763,7 +1251,7 @@ export class WorkspaceTools {
763
1251
  }
764
1252
  const decision = await this.approvalHandler(request);
765
1253
  if (decision === "allow_session") {
766
- this.sessionApprovals.add(permission);
1254
+ this.sessionApprovals.add(approvalKey);
767
1255
  }
768
1256
  return {
769
1257
  request,
@@ -775,7 +1263,7 @@ export class WorkspaceTools {
775
1263
  const resolvedPath = await realpath(absolutePath).catch((error) => {
776
1264
  throw new Error(`file not found or unreadable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
777
1265
  });
778
- await assertInsideWorkspace(await this.rootRealPath, resolvedPath, requestedPath);
1266
+ await this.assertSafeResolvedWorkspacePath(resolvedPath, requestedPath);
779
1267
  return resolvedPath;
780
1268
  }
781
1269
  async resolveDocumentPath(requestedPath) {
@@ -800,7 +1288,7 @@ export class WorkspaceTools {
800
1288
  throw new Error(`Path escapes workspace: ${requestedPath}. Enable /experimental file-analysis to inspect external files.`);
801
1289
  }
802
1290
  const extension = path.extname(trimmedPath).toLowerCase();
803
- if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && !isImageFile(trimmedPath)) {
1291
+ if (!isLikelyTextFile(trimmedPath) && extension !== ".pdf" && extension !== ".docx" && extension !== ".doc" && !isImageFile(trimmedPath)) {
804
1292
  throw new Error(`external file analysis does not support ${extension || "this file type"} yet.`);
805
1293
  }
806
1294
  const resolvedPath = await realpath(trimmedPath).catch((error) => {
@@ -820,15 +1308,74 @@ export class WorkspaceTools {
820
1308
  const existingParent = await findNearestExistingParent(absolutePath);
821
1309
  const parentRealPath = await realpath(existingParent);
822
1310
  await assertInsideWorkspace(rootRealPath, parentRealPath, requestedPath);
1311
+ assertNotSensitiveResolvedPath(rootRealPath, parentRealPath, requestedPath);
823
1312
  const targetStat = await lstat(absolutePath).catch(() => null);
824
1313
  if (targetStat) {
825
1314
  const resolvedTargetPath = await realpath(absolutePath).catch((error) => {
826
1315
  throw new Error(`file not writable: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`);
827
1316
  });
828
- await assertInsideWorkspace(rootRealPath, resolvedTargetPath, requestedPath);
1317
+ await this.assertSafeResolvedWorkspacePath(resolvedTargetPath, requestedPath);
829
1318
  }
830
1319
  return absolutePath;
831
1320
  }
1321
+ async assertSafeResolvedWorkspacePath(resolvedPath, requestedPath) {
1322
+ const rootRealPath = await this.rootRealPath;
1323
+ await assertInsideWorkspace(rootRealPath, resolvedPath, requestedPath);
1324
+ assertNotSensitiveResolvedPath(rootRealPath, resolvedPath, requestedPath);
1325
+ }
1326
+ async validatePatchTargets(patchContent) {
1327
+ if (patchCreatesSymlink(patchContent)) {
1328
+ return "apply_patch denied symlink patches.";
1329
+ }
1330
+ for (const targetPath of extractPatchTargetPaths(patchContent)) {
1331
+ if (isPlaceholderPath(targetPath)) {
1332
+ return `apply_patch denied placeholder path: ${targetPath}`;
1333
+ }
1334
+ if (isSensitivePath(targetPath) || targetPath === ".patchpilot" || targetPath.startsWith(".patchpilot/")) {
1335
+ return `apply_patch denied sensitive path: ${targetPath}`;
1336
+ }
1337
+ try {
1338
+ const absolutePath = this.resolveInsideWorkspace(targetPath);
1339
+ const existingPath = await realpath(absolutePath).catch(() => null);
1340
+ if (existingPath) {
1341
+ await this.assertSafeResolvedWorkspacePath(existingPath, targetPath);
1342
+ }
1343
+ else {
1344
+ const parentRealPath = await realpath(await findNearestExistingParent(absolutePath));
1345
+ await assertInsideWorkspace(await this.rootRealPath, parentRealPath, targetPath);
1346
+ assertNotSensitiveResolvedPath(await this.rootRealPath, parentRealPath, targetPath);
1347
+ }
1348
+ }
1349
+ catch (error) {
1350
+ return error instanceof Error ? error.message : String(error);
1351
+ }
1352
+ }
1353
+ return null;
1354
+ }
1355
+ async validateShellPathArguments(command) {
1356
+ const tokens = tokenizeShellCommand(command);
1357
+ for (const segment of splitPipeline(tokens)) {
1358
+ for (const token of segment.slice(1)) {
1359
+ const normalizedToken = stripQuotes(token);
1360
+ if (!normalizedToken || shellOperatorTokens.has(normalizedToken) || normalizedToken.startsWith("-")) {
1361
+ continue;
1362
+ }
1363
+ const absolutePath = toAbsoluteShellPath(normalizedToken) ?? path.resolve(this.root, normalizedToken);
1364
+ const existingPath = await lstat(absolutePath).catch(() => null);
1365
+ if (!existingPath) {
1366
+ continue;
1367
+ }
1368
+ try {
1369
+ const resolvedPath = await realpath(absolutePath);
1370
+ await this.assertSafeResolvedWorkspacePath(resolvedPath, normalizedToken);
1371
+ }
1372
+ catch (error) {
1373
+ return error instanceof Error ? error.message : String(error);
1374
+ }
1375
+ }
1376
+ }
1377
+ return null;
1378
+ }
832
1379
  }
833
1380
  async function walkFiles(startPath, workspaceRoot, workspaceRealRoot, maxDepth, maxEntries) {
834
1381
  const results = [];
@@ -972,6 +1519,15 @@ async function assertInsideWorkspace(workspaceRealRoot, candidatePath, requested
972
1519
  throw new Error(`Path escapes workspace: ${requestedPath}`);
973
1520
  }
974
1521
  }
1522
+ function assertNotSensitiveResolvedPath(workspaceRealRoot, candidatePath, requestedPath) {
1523
+ const relativePath = normalizeSlashPath(path.relative(workspaceRealRoot, candidatePath));
1524
+ if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
1525
+ return;
1526
+ }
1527
+ if (isSensitivePath(relativePath) || relativePath === ".patchpilot" || relativePath.startsWith(".patchpilot/")) {
1528
+ throw new Error(`Path resolves to sensitive workspace path: ${requestedPath}`);
1529
+ }
1530
+ }
975
1531
  async function findNearestExistingParent(absolutePath) {
976
1532
  let currentPath = path.dirname(absolutePath);
977
1533
  while (true) {
@@ -1056,6 +1612,73 @@ function runGitApply(patchContent, cwd, timeoutMs, signal) {
1056
1612
  function readString(value, fallback) {
1057
1613
  return typeof value === "string" ? value : fallback;
1058
1614
  }
1615
+ function readOptionalString(value) {
1616
+ return typeof value === "string" ? value : undefined;
1617
+ }
1618
+ function countOccurrences(value, needle) {
1619
+ if (!needle) {
1620
+ return 0;
1621
+ }
1622
+ let count = 0;
1623
+ let index = 0;
1624
+ while (true) {
1625
+ index = value.indexOf(needle, index);
1626
+ if (index === -1) {
1627
+ return count;
1628
+ }
1629
+ count += 1;
1630
+ index += needle.length;
1631
+ }
1632
+ }
1633
+ function normalizePossiblyEscapedFileContent(content, filePath) {
1634
+ if (!shouldDecodeEscapedFileContent(content, filePath)) {
1635
+ return {
1636
+ content,
1637
+ normalized: false
1638
+ };
1639
+ }
1640
+ const decoded = decodeCommonJsonStringEscapes(content);
1641
+ return {
1642
+ content: decoded,
1643
+ normalized: decoded !== content
1644
+ };
1645
+ }
1646
+ function shouldDecodeEscapedFileContent(content, filePath) {
1647
+ const escapedNewlines = countOccurrences(content, "\\n");
1648
+ if (escapedNewlines === 0) {
1649
+ return false;
1650
+ }
1651
+ const realNewlines = countOccurrences(content, "\n");
1652
+ if (realNewlines > 0 && realNewlines >= escapedNewlines) {
1653
+ return false;
1654
+ }
1655
+ const extension = path.extname(filePath).toLowerCase();
1656
+ const likelySourceOrMarkup = textFileExtensions.has(extension);
1657
+ if (!likelySourceOrMarkup) {
1658
+ return false;
1659
+ }
1660
+ const hasEscapedQuotes = content.includes('\\"') || content.includes("\\'");
1661
+ const hasSourceMarkers = /(?:<!doctype|<html|<\/\w+>|function\s|const\s|let\s|class\s|import\s|export\s|{\s*\\n|;\s*\\n|#\s|\/\*)/i.test(content);
1662
+ return hasEscapedQuotes || escapedNewlines >= 2 || hasSourceMarkers;
1663
+ }
1664
+ function decodeCommonJsonStringEscapes(content) {
1665
+ return content
1666
+ .replace(/\\r\\n/g, "\n")
1667
+ .replace(/\\n/g, "\n")
1668
+ .replace(/\\r/g, "\n")
1669
+ .replace(/\\t/g, "\t")
1670
+ .replace(/\\"/g, "\"")
1671
+ .replace(/\\'/g, "'");
1672
+ }
1673
+ function textContentMetadata(content) {
1674
+ const realNewlines = countOccurrences(content, "\n");
1675
+ return {
1676
+ lineCount: content.length === 0 ? 0 : realNewlines + 1,
1677
+ realNewlines,
1678
+ literalBackslashN: countOccurrences(content, "\\n"),
1679
+ literalEscapedQuotes: countOccurrences(content, '\\"')
1680
+ };
1681
+ }
1059
1682
  function readNumber(value, fallback) {
1060
1683
  if (typeof value === "number" && Number.isFinite(value)) {
1061
1684
  return Math.trunc(value);
@@ -1081,7 +1704,7 @@ function isPlaceholderPath(value) {
1081
1704
  const normalizedValue = value.trim().toLowerCase().replaceAll("\\", "/");
1082
1705
  return ["relative/path", "path/to/file", "file/path", "<path>", "<file>", "filename"].includes(normalizedValue);
1083
1706
  }
1084
- function isSensitivePath(value) {
1707
+ export function isSensitivePath(value) {
1085
1708
  const normalizedPath = value.trim().replaceAll("\\", "/");
1086
1709
  return normalizedPath
1087
1710
  .split("/")
@@ -1111,24 +1734,122 @@ function isLikelyTextFile(filePath) {
1111
1734
  return textFileExtensions.has(path.extname(filePath).toLowerCase());
1112
1735
  }
1113
1736
  function isImageFile(filePath) {
1114
- return [".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(path.extname(filePath).toLowerCase());
1737
+ return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".heic", ".heif"].includes(path.extname(filePath).toLowerCase());
1738
+ }
1739
+ function normalizeDocumentInspectionMode(mode) {
1740
+ const normalizedMode = mode.trim().toLowerCase();
1741
+ return normalizedMode === "local" || normalizedMode === "ocr" ? normalizedMode : "auto";
1115
1742
  }
1116
- async function inspectImageFile(filePath) {
1743
+ function mergeFallbackDocumentResult(providerResult, fallbackResult) {
1744
+ if (fallbackResult.ok) {
1745
+ return {
1746
+ ...fallbackResult,
1747
+ content: [
1748
+ "provider_analysis_error:",
1749
+ providerResult.content,
1750
+ "",
1751
+ "local_fallback:",
1752
+ fallbackResult.content
1753
+ ].join("\n")
1754
+ };
1755
+ }
1756
+ return providerResult;
1757
+ }
1758
+ function hasUsefulExtractedText(result) {
1759
+ if (!result.ok) {
1760
+ return false;
1761
+ }
1762
+ const normalizedContent = result.content.trim().toLowerCase();
1763
+ return Boolean(normalizedContent) && !normalizedContent.startsWith("no extractable ");
1764
+ }
1765
+ async function inspectImageFile(filePath, documentAnalyzer, signal, mode = "auto", providerTimeoutMs = 90_000) {
1117
1766
  const buffer = await readFile(filePath);
1118
1767
  const dimensions = readImageDimensions(buffer, path.extname(filePath).toLowerCase());
1768
+ const metadata = [
1769
+ `image: ${path.basename(filePath)}`,
1770
+ `type: ${path.extname(filePath).toLowerCase().replace(".", "") || "unknown"}`,
1771
+ `size: ${buffer.length} bytes`,
1772
+ dimensions ? `dimensions: ${dimensions.width}x${dimensions.height}` : "dimensions: unknown"
1773
+ ];
1774
+ if (documentAnalyzer) {
1775
+ try {
1776
+ const analysis = await runDocumentAnalyzer(documentAnalyzer, {
1777
+ path: filePath,
1778
+ prompt: "Analyze this image for PatchPilot. Extract all visible text exactly when possible, then describe the important visual elements, layout, UI state, and any errors or warnings.",
1779
+ signal
1780
+ }, providerTimeoutMs);
1781
+ return {
1782
+ ok: true,
1783
+ summary: `analyzed image ${path.basename(filePath)} with provider file input`,
1784
+ content: [...metadata, "", "provider_analysis:", clip(analysis, 20_000)].join("\n"),
1785
+ tool: "inspect_document",
1786
+ category: toolSpecs.inspect_document.category
1787
+ };
1788
+ }
1789
+ catch (error) {
1790
+ metadata.push("", `provider_analysis_error: ${error instanceof Error ? error.message : String(error)}`);
1791
+ }
1792
+ }
1793
+ if (mode === "ocr" || mode === "local") {
1794
+ const ocrText = await extractImageTextWithTesseract(filePath, signal);
1795
+ if (ocrText) {
1796
+ metadata.push("", "ocr_text:", clip(ocrText, 20_000));
1797
+ }
1798
+ }
1799
+ if (documentAnalyzer && mode === "auto") {
1800
+ metadata.push("", "analysis_status: metadata_only");
1801
+ return {
1802
+ ok: false,
1803
+ summary: `image analysis failed for ${path.basename(filePath)}; only metadata was available`,
1804
+ content: metadata.join("\n"),
1805
+ tool: "inspect_document",
1806
+ category: toolSpecs.inspect_document.category,
1807
+ metadata: {
1808
+ analysisStatus: "metadata_only"
1809
+ }
1810
+ };
1811
+ }
1119
1812
  return {
1120
1813
  ok: true,
1121
1814
  summary: `inspected image ${path.basename(filePath)}`,
1122
- content: [
1123
- `image: ${path.basename(filePath)}`,
1124
- `type: ${path.extname(filePath).toLowerCase().replace(".", "") || "unknown"}`,
1125
- `size: ${buffer.length} bytes`,
1126
- dimensions ? `dimensions: ${dimensions.width}x${dimensions.height}` : "dimensions: unknown"
1127
- ].join("\n"),
1815
+ content: metadata.join("\n"),
1128
1816
  tool: "inspect_document",
1129
1817
  category: toolSpecs.inspect_document.category
1130
1818
  };
1131
1819
  }
1820
+ async function runDocumentAnalyzer(documentAnalyzer, request, timeoutMs = 180_000) {
1821
+ const controller = new AbortController();
1822
+ const abort = () => controller.abort();
1823
+ let timeout;
1824
+ const timeoutPromise = new Promise((_, reject) => {
1825
+ timeout = setTimeout(() => {
1826
+ controller.abort();
1827
+ reject(new Error(`provider file analysis timed out after ${Math.round(timeoutMs / 1000)}s`));
1828
+ }, timeoutMs);
1829
+ });
1830
+ request.signal?.addEventListener("abort", abort, { once: true });
1831
+ try {
1832
+ return await Promise.race([
1833
+ documentAnalyzer({
1834
+ ...request,
1835
+ signal: controller.signal
1836
+ }),
1837
+ timeoutPromise
1838
+ ]);
1839
+ }
1840
+ catch (error) {
1841
+ if (controller.signal.aborted && !request.signal?.aborted) {
1842
+ throw new Error(`provider file analysis timed out after ${Math.round(timeoutMs / 1000)}s`);
1843
+ }
1844
+ throw error;
1845
+ }
1846
+ finally {
1847
+ if (timeout) {
1848
+ clearTimeout(timeout);
1849
+ }
1850
+ request.signal?.removeEventListener("abort", abort);
1851
+ }
1852
+ }
1132
1853
  function readImageDimensions(buffer, extension) {
1133
1854
  if (extension === ".png" && buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
1134
1855
  return {
@@ -1182,6 +1903,38 @@ async function extractPdfText(filePath, timeoutMs, signal) {
1182
1903
  return denied(`PDF text extraction needs pdftotext on PATH or a text-based PDF. ${error instanceof Error ? error.message : String(error)}`);
1183
1904
  }
1184
1905
  }
1906
+ async function extractImageTextWithTesseract(filePath, signal) {
1907
+ let cleanupDir = "";
1908
+ let inputPath = filePath;
1909
+ try {
1910
+ const extension = path.extname(filePath).toLowerCase();
1911
+ if (extension === ".heic" || extension === ".heif") {
1912
+ cleanupDir = await mkdtemp(path.join(tmpdir(), "patchpilot-ocr-"));
1913
+ inputPath = path.join(cleanupDir, "image.png");
1914
+ await execFileAsync("sips", ["-s", "format", "png", filePath, "--out", inputPath], {
1915
+ timeout: 60_000,
1916
+ signal,
1917
+ windowsHide: true
1918
+ });
1919
+ }
1920
+ const { stdout } = await execFileAsync("tesseract", [inputPath, "stdout", "-l", "eng+deu"], {
1921
+ timeout: 90_000,
1922
+ maxBuffer: 2_000_000,
1923
+ signal,
1924
+ windowsHide: true
1925
+ });
1926
+ const text = stdout.trim();
1927
+ return text || null;
1928
+ }
1929
+ catch {
1930
+ return null;
1931
+ }
1932
+ finally {
1933
+ if (cleanupDir) {
1934
+ await rm(cleanupDir, { recursive: true, force: true });
1935
+ }
1936
+ }
1937
+ }
1185
1938
  async function extractDocxText(filePath) {
1186
1939
  try {
1187
1940
  const archive = await readFile(filePath);
@@ -1197,6 +1950,147 @@ async function extractDocxText(filePath) {
1197
1950
  return denied(`DOCX text extraction needs unzip on PATH and a valid .docx file. ${error instanceof Error ? error.message : String(error)}`);
1198
1951
  }
1199
1952
  }
1953
+ async function extractLegacyDocText(filePath, timeoutMs, signal) {
1954
+ try {
1955
+ const { stdout } = await execFileAsync("textutil", ["-convert", "txt", "-stdout", filePath], {
1956
+ timeout: timeoutMs,
1957
+ signal,
1958
+ maxBuffer: 2_000_000
1959
+ });
1960
+ return {
1961
+ ok: true,
1962
+ summary: `extracted text from ${path.basename(filePath)}`,
1963
+ content: clip(stdout || "No extractable DOC text found.", 20_000)
1964
+ };
1965
+ }
1966
+ catch (error) {
1967
+ return denied(`DOC text extraction needs macOS textutil and a valid .doc file. ${error instanceof Error ? error.message : String(error)}`);
1968
+ }
1969
+ }
1970
+ function createSimplePdf(content, title) {
1971
+ const lines = wrapPdfText(`${title ? `${title}\n\n` : ""}${content}`, 92).slice(0, 44);
1972
+ const escapedLines = lines.map((line) => `(${escapePdfString(line)}) Tj`).join("\n0 -14 Td\n");
1973
+ const stream = `BT\n/F1 11 Tf\n50 780 Td\n14 TL\n${escapedLines}\nET`;
1974
+ const objects = [
1975
+ "<< /Type /Catalog /Pages 2 0 R >>",
1976
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1977
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
1978
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
1979
+ `<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`
1980
+ ];
1981
+ const chunks = ["%PDF-1.4\n"];
1982
+ const offsets = [0];
1983
+ for (const [index, object] of objects.entries()) {
1984
+ offsets.push(Buffer.byteLength(chunks.join(""), "utf8"));
1985
+ chunks.push(`${index + 1} 0 obj\n${object}\nendobj\n`);
1986
+ }
1987
+ const xrefOffset = Buffer.byteLength(chunks.join(""), "utf8");
1988
+ chunks.push(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`);
1989
+ for (const offset of offsets.slice(1)) {
1990
+ chunks.push(`${offset.toString().padStart(10, "0")} 00000 n \n`);
1991
+ }
1992
+ chunks.push(`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`);
1993
+ return Buffer.from(chunks.join(""), "utf8");
1994
+ }
1995
+ function wrapPdfText(value, width) {
1996
+ const lines = [];
1997
+ for (const rawLine of value.replace(/\r\n?/g, "\n").split("\n")) {
1998
+ let line = rawLine.trimEnd();
1999
+ while (line.length > width) {
2000
+ const breakAt = Math.max(line.lastIndexOf(" ", width), 1);
2001
+ lines.push(line.slice(0, breakAt).trimEnd());
2002
+ line = line.slice(breakAt).trimStart();
2003
+ }
2004
+ lines.push(line);
2005
+ }
2006
+ return lines;
2007
+ }
2008
+ function escapePdfString(value) {
2009
+ return value.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
2010
+ }
2011
+ function createSimpleDocx(content, title) {
2012
+ const paragraphs = `${title ? `${title}\n\n` : ""}${content}`
2013
+ .replace(/\r\n?/g, "\n")
2014
+ .split(/\n{2,}/)
2015
+ .map((paragraph) => paragraph.trim())
2016
+ .filter(Boolean);
2017
+ const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2018
+ <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paragraphs
2019
+ .map((paragraph) => `<w:p><w:r><w:t xml:space="preserve">${escapeXml(paragraph)}</w:t></w:r></w:p>`)
2020
+ .join("")}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`;
2021
+ return createZip([
2022
+ {
2023
+ name: "[Content_Types].xml",
2024
+ content: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
2025
+ },
2026
+ {
2027
+ name: "_rels/.rels",
2028
+ content: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
2029
+ },
2030
+ {
2031
+ name: "word/document.xml",
2032
+ content: documentXml
2033
+ }
2034
+ ]);
2035
+ }
2036
+ function createZip(entries) {
2037
+ const localRecords = [];
2038
+ const centralRecords = [];
2039
+ let offset = 0;
2040
+ for (const entry of entries) {
2041
+ const name = Buffer.from(entry.name);
2042
+ const content = Buffer.from(entry.content, "utf8");
2043
+ const compressed = deflateRawSync(content);
2044
+ const crc = crc32(content);
2045
+ const localHeader = Buffer.alloc(30);
2046
+ localHeader.writeUInt32LE(0x04034b50, 0);
2047
+ localHeader.writeUInt16LE(20, 4);
2048
+ localHeader.writeUInt16LE(8, 8);
2049
+ localHeader.writeUInt32LE(crc, 14);
2050
+ localHeader.writeUInt32LE(compressed.length, 18);
2051
+ localHeader.writeUInt32LE(content.length, 22);
2052
+ localHeader.writeUInt16LE(name.length, 26);
2053
+ const localRecord = Buffer.concat([localHeader, name, compressed]);
2054
+ localRecords.push(localRecord);
2055
+ const centralHeader = Buffer.alloc(46);
2056
+ centralHeader.writeUInt32LE(0x02014b50, 0);
2057
+ centralHeader.writeUInt16LE(20, 4);
2058
+ centralHeader.writeUInt16LE(20, 6);
2059
+ centralHeader.writeUInt16LE(8, 10);
2060
+ centralHeader.writeUInt32LE(crc, 16);
2061
+ centralHeader.writeUInt32LE(compressed.length, 20);
2062
+ centralHeader.writeUInt32LE(content.length, 24);
2063
+ centralHeader.writeUInt16LE(name.length, 28);
2064
+ centralHeader.writeUInt32LE(offset, 42);
2065
+ centralRecords.push(Buffer.concat([centralHeader, name]));
2066
+ offset += localRecord.length;
2067
+ }
2068
+ const centralDirectory = Buffer.concat(centralRecords);
2069
+ const endRecord = Buffer.alloc(22);
2070
+ endRecord.writeUInt32LE(0x06054b50, 0);
2071
+ endRecord.writeUInt16LE(entries.length, 8);
2072
+ endRecord.writeUInt16LE(entries.length, 10);
2073
+ endRecord.writeUInt32LE(centralDirectory.length, 12);
2074
+ endRecord.writeUInt32LE(offset, 16);
2075
+ return Buffer.concat([...localRecords, centralDirectory, endRecord]);
2076
+ }
2077
+ function crc32(buffer) {
2078
+ let crc = 0xffffffff;
2079
+ for (const byte of buffer) {
2080
+ crc ^= byte;
2081
+ for (let index = 0; index < 8; index += 1) {
2082
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
2083
+ }
2084
+ }
2085
+ return (crc ^ 0xffffffff) >>> 0;
2086
+ }
2087
+ function escapeXml(value) {
2088
+ return value
2089
+ .replaceAll("&", "&amp;")
2090
+ .replaceAll("<", "&lt;")
2091
+ .replaceAll(">", "&gt;")
2092
+ .replaceAll('"', "&quot;");
2093
+ }
1200
2094
  function readZipEntryText(archive, entryName) {
1201
2095
  const endOfCentralDirectoryOffset = findEndOfCentralDirectory(archive);
1202
2096
  if (endOfCentralDirectoryOffset < 0) {
@@ -1269,36 +2163,124 @@ function clip(content, maxLength) {
1269
2163
  return `${content.slice(0, maxLength)}\n...[clipped ${content.length - maxLength} chars]`;
1270
2164
  }
1271
2165
  function previewPatch(patchContent) {
1272
- const changedFiles = patchContent
1273
- .split(/\r?\n/)
1274
- .filter((line) => line.startsWith("+++ ") || line.startsWith("--- "))
1275
- .map((line) => line.slice(4).replace(/^a\//, "").replace(/^b\//, ""))
1276
- .filter((file) => file !== "/dev/null");
2166
+ const changedFiles = extractPatchTargetPaths(patchContent);
1277
2167
  const uniqueFiles = [...new Set(changedFiles)].slice(0, 6);
1278
2168
  const fileSummary = uniqueFiles.length > 0 ? uniqueFiles.join(", ") : "unknown files";
1279
2169
  const added = patchContent.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
1280
2170
  const removed = patchContent.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
1281
2171
  return `Apply patch to ${fileSummary} (+${added}/-${removed}).`;
1282
2172
  }
1283
- function validateShellCommand(command, workspaceRoot) {
1284
- const trimmedCommand = command.trim();
1285
- if (/[;&<>`$\n\r]/.test(trimmedCommand)) {
1286
- return "dangerous shell metacharacters are blocked; pipes are allowed, but command separators, redirects, expansion, and multiline commands are not.";
2173
+ function extractPatchTargetPaths(patchContent) {
2174
+ const paths = [];
2175
+ for (const line of patchContent.split(/\r?\n/)) {
2176
+ if (!line.startsWith("+++ ") && !line.startsWith("--- ")) {
2177
+ continue;
2178
+ }
2179
+ const rawPath = normalizePatchHeaderPath(line.slice(4));
2180
+ const normalizedPath = rawPath.replace(/^a\//, "").replace(/^b\//, "");
2181
+ if (normalizedPath && normalizedPath !== "/dev/null") {
2182
+ paths.push(normalizedPath);
2183
+ }
2184
+ }
2185
+ return [...new Set(paths)];
2186
+ }
2187
+ function patchCreatesSymlink(patchContent) {
2188
+ return /^new file mode 120000$/m.test(patchContent) || /^new mode 120000$/m.test(patchContent);
2189
+ }
2190
+ function normalizePatchHeaderPath(value) {
2191
+ const trimmedValue = value.trim();
2192
+ if (!trimmedValue || trimmedValue === "/dev/null") {
2193
+ return trimmedValue;
2194
+ }
2195
+ if (trimmedValue.startsWith("\"")) {
2196
+ try {
2197
+ return JSON.parse(trimmedValue);
2198
+ }
2199
+ catch {
2200
+ return trimmedValue.slice(1).split("\"")[0] ?? trimmedValue;
2201
+ }
1287
2202
  }
1288
- if (/(^|\s)\|\|(\s|$)/.test(trimmedCommand)) {
1289
- return "shell command separators are blocked; use a single pipeline.";
2203
+ return trimmedValue;
2204
+ }
2205
+ function validateShellCommand(command, workspaceRoot, options) {
2206
+ const trimmedCommand = command.trim();
2207
+ const syntax = analyzeShellSyntax(trimmedCommand);
2208
+ if (!options.allowMetacharacters) {
2209
+ if (syntax.hasShellChains || syntax.highRiskReasons.length > 0) {
2210
+ return {
2211
+ error: "shell metacharacters beyond pipes require /experimental shell-metacharacters; redirects, expansion, background jobs, OR chains, and multiline commands stay approval-gated."
2212
+ };
2213
+ }
1290
2214
  }
1291
- const tokens = trimmedCommand.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
2215
+ const tokens = syntax.tokens;
1292
2216
  if (tokens.length === 0) {
1293
- return "command is empty.";
2217
+ return { error: "command is empty." };
1294
2218
  }
1295
- for (const segment of splitPipeline(tokens)) {
2219
+ for (const segment of splitShellCommandSegments(tokens)) {
1296
2220
  const segmentError = validateShellSegment(segment);
1297
2221
  if (segmentError) {
1298
- return segmentError;
2222
+ return { error: segmentError };
1299
2223
  }
1300
2224
  }
1301
- for (const token of tokens.filter((value) => value !== "|")) {
2225
+ for (const token of tokens.filter((value) => !shellOperatorTokens.has(value))) {
2226
+ const normalizedToken = stripQuotes(token);
2227
+ if (isSensitivePath(normalizedToken)) {
2228
+ return { error: "sensitive path arguments are blocked." };
2229
+ }
2230
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(normalizedToken)) {
2231
+ return { error: "parent directory traversal is blocked." };
2232
+ }
2233
+ const absolutePath = toAbsoluteShellPath(normalizedToken);
2234
+ if (absolutePath) {
2235
+ const relativePath = path.relative(workspaceRoot, absolutePath);
2236
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
2237
+ return { error: "absolute path arguments outside the workspace are blocked. Use inspect_document with /experimental file-analysis for external files." };
2238
+ }
2239
+ }
2240
+ }
2241
+ return {
2242
+ error: null,
2243
+ requiresApprovalReason: syntax.highRiskReasons[0]
2244
+ };
2245
+ }
2246
+ function collectPackageScriptCommands(scripts, scriptName) {
2247
+ return [`pre${scriptName}`, scriptName, `post${scriptName}`]
2248
+ .filter((name) => typeof scripts[name] === "string")
2249
+ .map((name) => ({
2250
+ name,
2251
+ command: scripts[name] ?? ""
2252
+ }));
2253
+ }
2254
+ function validatePackageScriptCommands(commands, workspaceRoot) {
2255
+ for (const entry of commands) {
2256
+ const error = validatePackageScriptCommand(entry.command, workspaceRoot);
2257
+ if (error) {
2258
+ return `${entry.name}: ${error}`;
2259
+ }
2260
+ }
2261
+ return null;
2262
+ }
2263
+ function validatePackageScriptCommand(command, workspaceRoot) {
2264
+ const trimmedCommand = command.trim();
2265
+ if (!trimmedCommand) {
2266
+ return "package script is empty.";
2267
+ }
2268
+ if (/[;<>`$\n\r]/.test(trimmedCommand)) {
2269
+ return "dangerous shell metacharacters are blocked in package scripts before approval.";
2270
+ }
2271
+ if (/(^|\s)&($|\s)/.test(trimmedCommand)) {
2272
+ return "background shell execution is blocked in package scripts.";
2273
+ }
2274
+ const tokens = tokenizeShellCommand(trimmedCommand);
2275
+ for (const commandTokens of splitPackageCommandTokens(tokens)) {
2276
+ for (const segment of splitPipeline(commandTokens)) {
2277
+ const segmentError = validatePackageScriptSegment(segment);
2278
+ if (segmentError) {
2279
+ return segmentError;
2280
+ }
2281
+ }
2282
+ }
2283
+ for (const token of tokens.filter((value) => value !== "|" && value !== "&&" && value !== "||")) {
1302
2284
  const normalizedToken = stripQuotes(token);
1303
2285
  if (isSensitivePath(normalizedToken)) {
1304
2286
  return "sensitive path arguments are blocked.";
@@ -1310,12 +2292,153 @@ function validateShellCommand(command, workspaceRoot) {
1310
2292
  if (absolutePath) {
1311
2293
  const relativePath = path.relative(workspaceRoot, absolutePath);
1312
2294
  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
1313
- return "absolute path arguments outside the workspace are blocked. Use inspect_document with /experimental file-analysis for external files.";
2295
+ return "absolute path arguments outside the workspace are blocked.";
1314
2296
  }
1315
2297
  }
1316
2298
  }
1317
2299
  return null;
1318
2300
  }
2301
+ function splitPackageCommandTokens(tokens) {
2302
+ const commands = [[]];
2303
+ for (const token of tokens) {
2304
+ if (token === "&&" || token === "||") {
2305
+ commands.push([]);
2306
+ continue;
2307
+ }
2308
+ commands.at(-1)?.push(token);
2309
+ }
2310
+ return commands;
2311
+ }
2312
+ const shellOperatorTokens = new Set(["|", "&&", "||", ";", "&", "<", "<<", ">", ">>"]);
2313
+ function analyzeShellSyntax(command) {
2314
+ const tokens = [];
2315
+ const highRiskReasons = new Set();
2316
+ let current = "";
2317
+ let quote = null;
2318
+ let escaping = false;
2319
+ let hasShellChains = false;
2320
+ const pushCurrent = () => {
2321
+ if (current) {
2322
+ tokens.push(current);
2323
+ current = "";
2324
+ }
2325
+ };
2326
+ const pushOperator = (operator) => {
2327
+ pushCurrent();
2328
+ tokens.push(operator);
2329
+ };
2330
+ for (let index = 0; index < command.length; index += 1) {
2331
+ const char = command[index] ?? "";
2332
+ const next = command[index + 1] ?? "";
2333
+ if (escaping) {
2334
+ current += char;
2335
+ escaping = false;
2336
+ continue;
2337
+ }
2338
+ if (char === "\\") {
2339
+ current += char;
2340
+ escaping = true;
2341
+ continue;
2342
+ }
2343
+ if (quote) {
2344
+ current += char;
2345
+ if (quote === "\"" && (char === "$" || char === "`")) {
2346
+ highRiskReasons.add("shell expansion");
2347
+ }
2348
+ if (char === quote) {
2349
+ quote = null;
2350
+ }
2351
+ continue;
2352
+ }
2353
+ if (char === "'" || char === "\"") {
2354
+ current += char;
2355
+ quote = char;
2356
+ continue;
2357
+ }
2358
+ if (char === "\n" || char === "\r") {
2359
+ pushCurrent();
2360
+ highRiskReasons.add("multiline command");
2361
+ continue;
2362
+ }
2363
+ if (/\s/.test(char)) {
2364
+ pushCurrent();
2365
+ continue;
2366
+ }
2367
+ if (char === "|") {
2368
+ if (next === "|") {
2369
+ pushOperator("||");
2370
+ highRiskReasons.add("OR chain");
2371
+ index += 1;
2372
+ }
2373
+ else {
2374
+ pushOperator("|");
2375
+ }
2376
+ continue;
2377
+ }
2378
+ if (char === "&") {
2379
+ if (next === "&") {
2380
+ pushOperator("&&");
2381
+ hasShellChains = true;
2382
+ index += 1;
2383
+ }
2384
+ else {
2385
+ pushOperator("&");
2386
+ highRiskReasons.add("background execution");
2387
+ }
2388
+ continue;
2389
+ }
2390
+ if (char === ";") {
2391
+ pushOperator(";");
2392
+ hasShellChains = true;
2393
+ continue;
2394
+ }
2395
+ if (char === "<" || char === ">") {
2396
+ const operator = next === char ? `${char}${next}` : char;
2397
+ pushOperator(operator);
2398
+ highRiskReasons.add("redirection");
2399
+ if (next === char) {
2400
+ index += 1;
2401
+ }
2402
+ continue;
2403
+ }
2404
+ if (char === "$" || char === "`") {
2405
+ highRiskReasons.add("shell expansion");
2406
+ }
2407
+ if (char === "*" || char === "?" || char === "[") {
2408
+ highRiskReasons.add("glob expansion");
2409
+ }
2410
+ current += char;
2411
+ }
2412
+ pushCurrent();
2413
+ return {
2414
+ tokens,
2415
+ hasShellChains,
2416
+ highRiskReasons: [...highRiskReasons]
2417
+ };
2418
+ }
2419
+ function tokenizeShellCommand(command) {
2420
+ return analyzeShellSyntax(command).tokens;
2421
+ }
2422
+ function validatePackageScriptSegment(tokens) {
2423
+ if (tokens.length === 0) {
2424
+ return "empty shell pipeline segment.";
2425
+ }
2426
+ const executable = stripQuotes(tokens[0] ?? "").toLowerCase();
2427
+ const subcommand = findCommandSubcommand(executable, tokens.slice(1));
2428
+ if (["bash", "sh", "zsh", "fish", "pwsh", "powershell", "powershell.exe"].includes(executable)) {
2429
+ return `executable "${executable}" is blocked in package scripts.`;
2430
+ }
2431
+ if (["rm", "rmdir", "mv", "cp"].includes(executable) && tokens.some((token) => /^-.*[fRr]/.test(stripQuotes(token)))) {
2432
+ return `destructive ${executable} flags are blocked.`;
2433
+ }
2434
+ if (executable === "git" && subcommand && ["clean", "reset", "push", "checkout", "switch", "branch", "tag"].includes(subcommand)) {
2435
+ return `git ${subcommand} is blocked in package scripts.`;
2436
+ }
2437
+ if (executable === "npm" && subcommand && ["publish", "unpublish", "dist-tag"].includes(subcommand)) {
2438
+ return `npm ${subcommand} is blocked in package scripts.`;
2439
+ }
2440
+ return null;
2441
+ }
1319
2442
  function validateShellSegment(tokens) {
1320
2443
  if (tokens.length === 0) {
1321
2444
  return "empty shell pipeline segment.";
@@ -1336,6 +2459,17 @@ function validateShellSegment(tokens) {
1336
2459
  }
1337
2460
  return null;
1338
2461
  }
2462
+ function splitShellCommandSegments(tokens) {
2463
+ const segments = [[]];
2464
+ for (const token of tokens) {
2465
+ if (["|", "&&", "||", ";", "&"].includes(token)) {
2466
+ segments.push([]);
2467
+ continue;
2468
+ }
2469
+ segments.at(-1)?.push(token);
2470
+ }
2471
+ return segments;
2472
+ }
1339
2473
  function splitPipeline(tokens) {
1340
2474
  const segments = [[]];
1341
2475
  for (const token of tokens) {
@@ -1376,14 +2510,59 @@ function toAbsoluteShellPath(value) {
1376
2510
  return path.resolve(value);
1377
2511
  }
1378
2512
  if (value === "~" || value.startsWith("~/")) {
1379
- return path.resolve(process.env.HOME ?? "", value === "~" ? "." : value.slice(2));
2513
+ return path.resolve(homedir(), value === "~" ? "." : value.slice(2));
1380
2514
  }
1381
2515
  return null;
1382
2516
  }
1383
- function previewPackageScript(name, command, workspaceRoot) {
1384
- const risk = validateShellCommand(command, workspaceRoot);
2517
+ function previewPackageScriptSequence(name, commands, workspaceRoot) {
2518
+ const commandSummary = commands.map((entry) => `${entry.name}: ${entry.command}`).join(" && ");
2519
+ const risk = validatePackageScriptCommands(commands, workspaceRoot);
1385
2520
  const prefix = risk ? `Risky package script (${risk})` : "Run package script";
1386
- return `${prefix}: npm run ${name} -> ${clip(command, 220)}`;
2521
+ return `${prefix}: npm run ${name} -> ${clip(commandSummary, 220)}`;
2522
+ }
2523
+ function approvalScopeKey(tool, permission, args) {
2524
+ const target = approvalScopeTarget(tool, args);
2525
+ return `${permission}:${tool}:${target}`;
2526
+ }
2527
+ function approvalScopeTarget(tool, args) {
2528
+ switch (tool) {
2529
+ case "write_file":
2530
+ case "edit_file":
2531
+ case "create_pdf":
2532
+ case "create_docx":
2533
+ return `path:${normalizeScopeValue(readString(args.path, ""))}`;
2534
+ case "apply_patch":
2535
+ return `patch:${readString(args.patchHash, "") || stableHash(readString(args.patch, ""))}`;
2536
+ case "run_script":
2537
+ case "run_tests": {
2538
+ const script = normalizeScopeValue(readString(args.script, tool === "run_tests" ? "test" : ""));
2539
+ const command = normalizeCommandForScope(readString(args.command, ""));
2540
+ return `script:${script}:${stableHash(command)}`;
2541
+ }
2542
+ case "run_shell":
2543
+ return `command:${stableHash(normalizeCommandForScope(readString(args.command, "")))}`;
2544
+ case "inspect_document":
2545
+ return `external:${normalizeScopeValue(readString(args.path, ""))}`;
2546
+ case "memory_remember":
2547
+ return `memory:${stableHash(readString(args.content, ""))}`;
2548
+ default:
2549
+ return stableHash(JSON.stringify(args));
2550
+ }
2551
+ }
2552
+ function normalizeScopeValue(value) {
2553
+ return value.trim().replaceAll("\\", "/").replace(/\/+/g, "/").replace(/^\.\//, "") || ".";
2554
+ }
2555
+ function normalizeCommandForScope(value) {
2556
+ return value.trim().replace(/\s+/g, " ");
2557
+ }
2558
+ function stableHash(value) {
2559
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
2560
+ }
2561
+ function readStringRecord(value) {
2562
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2563
+ return {};
2564
+ }
2565
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
1387
2566
  }
1388
2567
  function stripQuotes(value) {
1389
2568
  return value.replace(/^['"]|['"]$/g, "");