longer-agent 0.1.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 (289) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/README.zh-CN.md +227 -0
  4. package/agent_templates/executor/agent.yaml +22 -0
  5. package/agent_templates/executor/system_prompt.md +17 -0
  6. package/agent_templates/explorer/agent.yaml +13 -0
  7. package/agent_templates/explorer/system_prompt.md +19 -0
  8. package/agent_templates/main/agent.yaml +7 -0
  9. package/agent_templates/main/system_prompt.md +45 -0
  10. package/configExample.yaml +83 -0
  11. package/dist/agents/agent.d.ts +79 -0
  12. package/dist/agents/agent.d.ts.map +1 -0
  13. package/dist/agents/agent.js +156 -0
  14. package/dist/agents/agent.js.map +1 -0
  15. package/dist/agents/tool-loop.d.ts +140 -0
  16. package/dist/agents/tool-loop.d.ts.map +1 -0
  17. package/dist/agents/tool-loop.js +465 -0
  18. package/dist/agents/tool-loop.js.map +1 -0
  19. package/dist/ask.d.ts +81 -0
  20. package/dist/ask.d.ts.map +1 -0
  21. package/dist/ask.js +34 -0
  22. package/dist/ask.js.map +1 -0
  23. package/dist/auth/openai-oauth.d.ts +66 -0
  24. package/dist/auth/openai-oauth.d.ts.map +1 -0
  25. package/dist/auth/openai-oauth.js +640 -0
  26. package/dist/auth/openai-oauth.js.map +1 -0
  27. package/dist/cli.d.ts +14 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +254 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/commands.d.ts +118 -0
  32. package/dist/commands.d.ts.map +1 -0
  33. package/dist/commands.js +862 -0
  34. package/dist/commands.js.map +1 -0
  35. package/dist/config.d.ts +130 -0
  36. package/dist/config.d.ts.map +1 -0
  37. package/dist/config.js +648 -0
  38. package/dist/config.js.map +1 -0
  39. package/dist/context-rendering.d.ts +69 -0
  40. package/dist/context-rendering.d.ts.map +1 -0
  41. package/dist/context-rendering.js +250 -0
  42. package/dist/context-rendering.js.map +1 -0
  43. package/dist/document-projection.d.ts +12 -0
  44. package/dist/document-projection.d.ts.map +1 -0
  45. package/dist/document-projection.js +75 -0
  46. package/dist/document-projection.js.map +1 -0
  47. package/dist/ephemeral-log.d.ts +15 -0
  48. package/dist/ephemeral-log.d.ts.map +1 -0
  49. package/dist/ephemeral-log.js +173 -0
  50. package/dist/ephemeral-log.js.map +1 -0
  51. package/dist/file-attach.d.ts +89 -0
  52. package/dist/file-attach.d.ts.map +1 -0
  53. package/dist/file-attach.js +571 -0
  54. package/dist/file-attach.js.map +1 -0
  55. package/dist/index.d.ts +29 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +43 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/init-wizard.d.ts +13 -0
  60. package/dist/init-wizard.d.ts.map +1 -0
  61. package/dist/init-wizard.js +328 -0
  62. package/dist/init-wizard.js.map +1 -0
  63. package/dist/log-entry.d.ts +104 -0
  64. package/dist/log-entry.d.ts.map +1 -0
  65. package/dist/log-entry.js +292 -0
  66. package/dist/log-entry.js.map +1 -0
  67. package/dist/log-projection.d.ts +73 -0
  68. package/dist/log-projection.d.ts.map +1 -0
  69. package/dist/log-projection.js +651 -0
  70. package/dist/log-projection.js.map +1 -0
  71. package/dist/mcp-client.d.ts +55 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +402 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-selection.d.ts +16 -0
  76. package/dist/model-selection.d.ts.map +1 -0
  77. package/dist/model-selection.js +181 -0
  78. package/dist/model-selection.js.map +1 -0
  79. package/dist/network-retry.d.ts +38 -0
  80. package/dist/network-retry.d.ts.map +1 -0
  81. package/dist/network-retry.js +140 -0
  82. package/dist/network-retry.js.map +1 -0
  83. package/dist/persistence.d.ts +104 -0
  84. package/dist/persistence.d.ts.map +1 -0
  85. package/dist/persistence.js +644 -0
  86. package/dist/persistence.js.map +1 -0
  87. package/dist/primitives/context.d.ts +29 -0
  88. package/dist/primitives/context.d.ts.map +1 -0
  89. package/dist/primitives/context.js +85 -0
  90. package/dist/primitives/context.js.map +1 -0
  91. package/dist/progress.d.ts +51 -0
  92. package/dist/progress.d.ts.map +1 -0
  93. package/dist/progress.js +229 -0
  94. package/dist/progress.js.map +1 -0
  95. package/dist/provider-presets.d.ts +34 -0
  96. package/dist/provider-presets.d.ts.map +1 -0
  97. package/dist/provider-presets.js +181 -0
  98. package/dist/provider-presets.js.map +1 -0
  99. package/dist/providers/anthropic.d.ts +32 -0
  100. package/dist/providers/anthropic.d.ts.map +1 -0
  101. package/dist/providers/anthropic.js +450 -0
  102. package/dist/providers/anthropic.js.map +1 -0
  103. package/dist/providers/base.d.ts +135 -0
  104. package/dist/providers/base.d.ts.map +1 -0
  105. package/dist/providers/base.js +104 -0
  106. package/dist/providers/base.js.map +1 -0
  107. package/dist/providers/glm.d.ts +18 -0
  108. package/dist/providers/glm.d.ts.map +1 -0
  109. package/dist/providers/glm.js +59 -0
  110. package/dist/providers/glm.js.map +1 -0
  111. package/dist/providers/kimi.d.ts +23 -0
  112. package/dist/providers/kimi.d.ts.map +1 -0
  113. package/dist/providers/kimi.js +89 -0
  114. package/dist/providers/kimi.js.map +1 -0
  115. package/dist/providers/minimax.d.ts +20 -0
  116. package/dist/providers/minimax.d.ts.map +1 -0
  117. package/dist/providers/minimax.js +192 -0
  118. package/dist/providers/minimax.js.map +1 -0
  119. package/dist/providers/openai-chat.d.ts +33 -0
  120. package/dist/providers/openai-chat.d.ts.map +1 -0
  121. package/dist/providers/openai-chat.js +543 -0
  122. package/dist/providers/openai-chat.js.map +1 -0
  123. package/dist/providers/openai-responses.d.ts +26 -0
  124. package/dist/providers/openai-responses.d.ts.map +1 -0
  125. package/dist/providers/openai-responses.js +443 -0
  126. package/dist/providers/openai-responses.js.map +1 -0
  127. package/dist/providers/openrouter.d.ts +24 -0
  128. package/dist/providers/openrouter.d.ts.map +1 -0
  129. package/dist/providers/openrouter.js +177 -0
  130. package/dist/providers/openrouter.js.map +1 -0
  131. package/dist/providers/registry.d.ts +7 -0
  132. package/dist/providers/registry.d.ts.map +1 -0
  133. package/dist/providers/registry.js +38 -0
  134. package/dist/providers/registry.js.map +1 -0
  135. package/dist/security/path.d.ts +51 -0
  136. package/dist/security/path.d.ts.map +1 -0
  137. package/dist/security/path.js +187 -0
  138. package/dist/security/path.js.map +1 -0
  139. package/dist/security/sensitive-files.d.ts +3 -0
  140. package/dist/security/sensitive-files.d.ts.map +1 -0
  141. package/dist/security/sensitive-files.js +41 -0
  142. package/dist/security/sensitive-files.js.map +1 -0
  143. package/dist/session.d.ts +446 -0
  144. package/dist/session.d.ts.map +1 -0
  145. package/dist/session.js +4595 -0
  146. package/dist/session.js.map +1 -0
  147. package/dist/settings.d.ts +46 -0
  148. package/dist/settings.d.ts.map +1 -0
  149. package/dist/settings.js +134 -0
  150. package/dist/settings.js.map +1 -0
  151. package/dist/show-context.d.ts +35 -0
  152. package/dist/show-context.d.ts.map +1 -0
  153. package/dist/show-context.js +320 -0
  154. package/dist/show-context.js.map +1 -0
  155. package/dist/skills/loader.d.ts +49 -0
  156. package/dist/skills/loader.d.ts.map +1 -0
  157. package/dist/skills/loader.js +166 -0
  158. package/dist/skills/loader.js.map +1 -0
  159. package/dist/summarize-context.d.ts +29 -0
  160. package/dist/summarize-context.d.ts.map +1 -0
  161. package/dist/summarize-context.js +247 -0
  162. package/dist/summarize-context.js.map +1 -0
  163. package/dist/templates/loader.d.ts +104 -0
  164. package/dist/templates/loader.d.ts.map +1 -0
  165. package/dist/templates/loader.js +514 -0
  166. package/dist/templates/loader.js.map +1 -0
  167. package/dist/tools/basic.d.ts +29 -0
  168. package/dist/tools/basic.d.ts.map +1 -0
  169. package/dist/tools/basic.js +2079 -0
  170. package/dist/tools/basic.js.map +1 -0
  171. package/dist/tools/comm.d.ts +17 -0
  172. package/dist/tools/comm.d.ts.map +1 -0
  173. package/dist/tools/comm.js +192 -0
  174. package/dist/tools/comm.js.map +1 -0
  175. package/dist/tools/web-fetch.d.ts +11 -0
  176. package/dist/tools/web-fetch.d.ts.map +1 -0
  177. package/dist/tools/web-fetch.js +237 -0
  178. package/dist/tools/web-fetch.js.map +1 -0
  179. package/dist/tools/web-search.d.ts +24 -0
  180. package/dist/tools/web-search.d.ts.map +1 -0
  181. package/dist/tools/web-search.js +51 -0
  182. package/dist/tools/web-search.js.map +1 -0
  183. package/dist/tui/app.d.ts +35 -0
  184. package/dist/tui/app.d.ts.map +1 -0
  185. package/dist/tui/app.js +1042 -0
  186. package/dist/tui/app.js.map +1 -0
  187. package/dist/tui/checkbox-picker.d.ts +35 -0
  188. package/dist/tui/checkbox-picker.d.ts.map +1 -0
  189. package/dist/tui/checkbox-picker.js +85 -0
  190. package/dist/tui/checkbox-picker.js.map +1 -0
  191. package/dist/tui/command-picker.d.ts +31 -0
  192. package/dist/tui/command-picker.d.ts.map +1 -0
  193. package/dist/tui/command-picker.js +113 -0
  194. package/dist/tui/command-picker.js.map +1 -0
  195. package/dist/tui/components/ask-panel.d.ts +21 -0
  196. package/dist/tui/components/ask-panel.d.ts.map +1 -0
  197. package/dist/tui/components/ask-panel.js +81 -0
  198. package/dist/tui/components/ask-panel.js.map +1 -0
  199. package/dist/tui/components/conversation-panel.d.ts +68 -0
  200. package/dist/tui/components/conversation-panel.d.ts.map +1 -0
  201. package/dist/tui/components/conversation-panel.js +611 -0
  202. package/dist/tui/components/conversation-panel.js.map +1 -0
  203. package/dist/tui/components/input-panel.d.ts +27 -0
  204. package/dist/tui/components/input-panel.d.ts.map +1 -0
  205. package/dist/tui/components/input-panel.js +725 -0
  206. package/dist/tui/components/input-panel.js.map +1 -0
  207. package/dist/tui/components/logo-panel.d.ts +14 -0
  208. package/dist/tui/components/logo-panel.d.ts.map +1 -0
  209. package/dist/tui/components/logo-panel.js +37 -0
  210. package/dist/tui/components/logo-panel.js.map +1 -0
  211. package/dist/tui/components/plan-panel.d.ts +10 -0
  212. package/dist/tui/components/plan-panel.d.ts.map +1 -0
  213. package/dist/tui/components/plan-panel.js +8 -0
  214. package/dist/tui/components/plan-panel.js.map +1 -0
  215. package/dist/tui/components/status-bar.d.ts +24 -0
  216. package/dist/tui/components/status-bar.d.ts.map +1 -0
  217. package/dist/tui/components/status-bar.js +80 -0
  218. package/dist/tui/components/status-bar.js.map +1 -0
  219. package/dist/tui/input/editor-state.d.ts +22 -0
  220. package/dist/tui/input/editor-state.d.ts.map +1 -0
  221. package/dist/tui/input/editor-state.js +157 -0
  222. package/dist/tui/input/editor-state.js.map +1 -0
  223. package/dist/tui/input/keymap.d.ts +3 -0
  224. package/dist/tui/input/keymap.d.ts.map +1 -0
  225. package/dist/tui/input/keymap.js +72 -0
  226. package/dist/tui/input/keymap.js.map +1 -0
  227. package/dist/tui/input/paste-slots.d.ts +17 -0
  228. package/dist/tui/input/paste-slots.d.ts.map +1 -0
  229. package/dist/tui/input/paste-slots.js +46 -0
  230. package/dist/tui/input/paste-slots.js.map +1 -0
  231. package/dist/tui/input/paste.d.ts +15 -0
  232. package/dist/tui/input/paste.d.ts.map +1 -0
  233. package/dist/tui/input/paste.js +35 -0
  234. package/dist/tui/input/paste.js.map +1 -0
  235. package/dist/tui/input/protocol.d.ts +9 -0
  236. package/dist/tui/input/protocol.d.ts.map +1 -0
  237. package/dist/tui/input/protocol.js +387 -0
  238. package/dist/tui/input/protocol.js.map +1 -0
  239. package/dist/tui/input/sanitize.d.ts +6 -0
  240. package/dist/tui/input/sanitize.d.ts.map +1 -0
  241. package/dist/tui/input/sanitize.js +20 -0
  242. package/dist/tui/input/sanitize.js.map +1 -0
  243. package/dist/tui/input/types.d.ts +18 -0
  244. package/dist/tui/input/types.d.ts.map +1 -0
  245. package/dist/tui/input/types.js +2 -0
  246. package/dist/tui/input/types.js.map +1 -0
  247. package/dist/tui/launch.d.ts +23 -0
  248. package/dist/tui/launch.d.ts.map +1 -0
  249. package/dist/tui/launch.js +104 -0
  250. package/dist/tui/launch.js.map +1 -0
  251. package/dist/tui/theme.d.ts +20 -0
  252. package/dist/tui/theme.d.ts.map +1 -0
  253. package/dist/tui/theme.js +29 -0
  254. package/dist/tui/theme.js.map +1 -0
  255. package/dist/tui/types.d.ts +136 -0
  256. package/dist/tui/types.d.ts.map +1 -0
  257. package/dist/tui/types.js +9 -0
  258. package/dist/tui/types.js.map +1 -0
  259. package/package.json +76 -0
  260. package/prompts/sections/agents_md.md +23 -0
  261. package/prompts/sections/important_log.md +16 -0
  262. package/prompts/sections/system_mechanisms.md +18 -0
  263. package/prompts/tools/apply_patch.md +31 -0
  264. package/prompts/tools/ask.md +18 -0
  265. package/prompts/tools/bash.md +13 -0
  266. package/prompts/tools/bash_background.md +9 -0
  267. package/prompts/tools/bash_output.md +9 -0
  268. package/prompts/tools/check_status.md +3 -0
  269. package/prompts/tools/diff.md +5 -0
  270. package/prompts/tools/edit_file.md +11 -0
  271. package/prompts/tools/glob.md +7 -0
  272. package/prompts/tools/grep.md +20 -0
  273. package/prompts/tools/kill_agent.md +3 -0
  274. package/prompts/tools/kill_shell.md +5 -0
  275. package/prompts/tools/list_dir.md +5 -0
  276. package/prompts/tools/plan.md +252 -0
  277. package/prompts/tools/read_file.md +9 -0
  278. package/prompts/tools/show_context.md +12 -0
  279. package/prompts/tools/skill.md +7 -0
  280. package/prompts/tools/spawn_agent.md +195 -0
  281. package/prompts/tools/summarize_context.md +122 -0
  282. package/prompts/tools/test.md +5 -0
  283. package/prompts/tools/wait.md +17 -0
  284. package/prompts/tools/web_fetch.md +9 -0
  285. package/prompts/tools/web_search.md +5 -0
  286. package/prompts/tools/write_file.md +11 -0
  287. package/skills/.staging/.gitkeep +0 -0
  288. package/skills/explain-code/SKILL.md +15 -0
  289. package/skills/skill-manager/SKILL.md +83 -0
@@ -0,0 +1,2079 @@
1
+ /**
2
+ * Built-in tool definitions and executors.
3
+ *
4
+ * 15 tools: read_file, list_dir, glob, grep, edit_file, write_file,
5
+ * apply_patch, bash, bash_background, bash_output, kill_shell,
6
+ * diff, test, web_search, web_fetch.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import { existsSync, statSync, readFileSync, readdirSync, realpathSync } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
+ import path from "node:path";
12
+ import { spawnSync } from "node:child_process";
13
+ import { ToolResult } from "../providers/base.js";
14
+ import { safePath, SafePathError, } from "../security/path.js";
15
+ import { getSensitiveFileReadReason } from "../security/sensitive-files.js";
16
+ import { WEB_SEARCH, toolBuiltinWebSearchPassthrough, } from "./web-search.js";
17
+ import { WEB_FETCH, toolWebFetch } from "./web-fetch.js";
18
+ import { isProjectedDocumentPath, loadProjectedDocumentView, projectedDocumentLabel, } from "../document-projection.js";
19
+ import { classifyFile, IMAGE_MEDIA_TYPES } from "../file-attach.js";
20
+ // ------------------------------------------------------------------
21
+ // Bash safety limits
22
+ // ------------------------------------------------------------------
23
+ const BASH_MAX_TIMEOUT = 600; // 10 minutes hard cap (seconds)
24
+ const BASH_DEFAULT_TIMEOUT = 60;
25
+ const BASH_MAX_OUTPUT_CHARS = 200_000; // ~200 KB text cap per stream
26
+ const BASH_TIMEOUT_KILL_SIGNAL = "SIGKILL";
27
+ const BASH_ENV_ALLOWLIST = new Set([
28
+ "PATH",
29
+ "HOME",
30
+ "SHELL",
31
+ "TERM",
32
+ "COLORTERM",
33
+ "LANG",
34
+ "LC_ALL",
35
+ "LC_CTYPE",
36
+ "LC_MESSAGES",
37
+ "TMPDIR",
38
+ "TMP",
39
+ "TEMP",
40
+ "PWD",
41
+ "USER",
42
+ "LOGNAME",
43
+ "TZ",
44
+ "NO_COLOR",
45
+ "FORCE_COLOR",
46
+ "CI",
47
+ "XDG_RUNTIME_DIR",
48
+ "XDG_CONFIG_HOME",
49
+ "XDG_CACHE_HOME",
50
+ "XDG_DATA_HOME",
51
+ ]);
52
+ // ------------------------------------------------------------------
53
+ // Read limits
54
+ // ------------------------------------------------------------------
55
+ const READ_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
56
+ const READ_MAX_LINES = 1000;
57
+ const READ_MAX_CHARS = 50_000;
58
+ const READ_MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB limit for images
59
+ // ------------------------------------------------------------------
60
+ // Search safety limits
61
+ // ------------------------------------------------------------------
62
+ const SEARCH_MAX_RESULTS = 50;
63
+ const SEARCH_MAX_DEPTH = 6;
64
+ const SEARCH_MAX_FILES = 2_000;
65
+ const SEARCH_MAX_FILE_SIZE = 1 * 1024 * 1024; // 1 MB per file
66
+ const SEARCH_MAX_TOTAL_BYTES = 8 * 1024 * 1024; // 8 MB total scanned text
67
+ const SEARCH_MAX_PATTERN_LENGTH = 300;
68
+ const SEARCH_MAX_DURATION_MS = 2_000;
69
+ // ------------------------------------------------------------------
70
+ // File write safety (Phase 5)
71
+ // ------------------------------------------------------------------
72
+ const FILE_WRITE_LOCKS = new Map();
73
+ // ======================================================================
74
+ // Tool definitions (provider-agnostic JSON Schema)
75
+ // ======================================================================
76
+ const READ = {
77
+ name: "read_file",
78
+ description: "Read the contents of a text file (max 50 MB). " +
79
+ "Some document formats such as PDF, DOCX, and XLSX are returned as an auto-extracted Markdown view of the original file. " +
80
+ "Returns line window plus file metadata (including mtime_ms) for optional optimistic concurrency checks. " +
81
+ "Each call returns at most 1000 lines and 50000 characters. " +
82
+ "If the file exceeds these limits, the output is truncated with a notice. " +
83
+ "Use start_line / end_line to navigate large files in multiple calls. " +
84
+ "If both are omitted, reads from the beginning up to the limit.",
85
+ parameters: {
86
+ type: "object",
87
+ properties: {
88
+ path: {
89
+ type: "string",
90
+ description: "Absolute or relative file path",
91
+ },
92
+ start_line: {
93
+ type: "integer",
94
+ description: "First line to read (1-indexed, inclusive). Defaults to 1.",
95
+ },
96
+ end_line: {
97
+ type: "integer",
98
+ description: "Last line to read (1-indexed, inclusive). " +
99
+ "Use -1 to read to the end of the file.",
100
+ },
101
+ },
102
+ required: ["path"],
103
+ },
104
+ summaryTemplate: "{agent} is reading {path}",
105
+ };
106
+ const LIST = {
107
+ name: "list_dir",
108
+ description: "List files and directories. Returns a tree up to 2 levels deep.",
109
+ parameters: {
110
+ type: "object",
111
+ properties: {
112
+ path: {
113
+ type: "string",
114
+ description: "Directory path (default: current directory)",
115
+ default: ".",
116
+ },
117
+ },
118
+ required: [],
119
+ },
120
+ summaryTemplate: "{agent} is listing {path}",
121
+ };
122
+ const EDIT = {
123
+ name: "edit_file",
124
+ description: "Apply a minimal patch to an existing file by replacing a unique string with a new string. The old_str must appear exactly once in the file.",
125
+ parameters: {
126
+ type: "object",
127
+ properties: {
128
+ path: { type: "string", description: "File path to edit" },
129
+ old_str: {
130
+ type: "string",
131
+ description: "Exact string to find (must be unique in the file)",
132
+ },
133
+ new_str: { type: "string", description: "Replacement string" },
134
+ expected_mtime_ms: {
135
+ type: "integer",
136
+ description: "Optional optimistic concurrency guard. " +
137
+ "If provided, edit is rejected when the file mtime differs (milliseconds since epoch).",
138
+ },
139
+ },
140
+ required: ["path", "old_str", "new_str"],
141
+ },
142
+ summaryTemplate: "{agent} is editing {path}",
143
+ };
144
+ const WRITE = {
145
+ name: "write_file",
146
+ description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
147
+ parameters: {
148
+ type: "object",
149
+ properties: {
150
+ path: { type: "string", description: "File path to write" },
151
+ content: { type: "string", description: "Full file content" },
152
+ expected_mtime_ms: {
153
+ type: "integer",
154
+ description: "Optional optimistic concurrency guard for overwrites. " +
155
+ "If provided, write is rejected when the existing file mtime differs (milliseconds since epoch).",
156
+ },
157
+ },
158
+ required: ["path", "content"],
159
+ },
160
+ summaryTemplate: "{agent} is writing to {path}",
161
+ };
162
+ const APPLY_PATCH = {
163
+ name: "apply_patch",
164
+ description: "Apply a structured multi-file patch. " +
165
+ "Use for multi-hunk edits, appending to large files, and coordinated file changes. " +
166
+ "Patch syntax uses explicit markers such as '*** Begin Patch', " +
167
+ "'*** Update File:', '*** Append File:', '*** Add File:', '*** Delete File:', and '*** End Patch'.",
168
+ parameters: {
169
+ type: "object",
170
+ properties: {
171
+ patch: {
172
+ type: "string",
173
+ description: "Full patch text. Example:\n" +
174
+ "*** Begin Patch\n" +
175
+ "*** Update File: src/app.ts\n" +
176
+ "@@\n" +
177
+ "-old line\n" +
178
+ "+new line\n" +
179
+ "*** End Patch",
180
+ },
181
+ },
182
+ required: ["patch"],
183
+ },
184
+ summaryTemplate: "{agent} is applying a patch",
185
+ };
186
+ const BASH = {
187
+ name: "bash",
188
+ description: "Execute a shell command and return stdout, stderr, and exit code.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ command: { type: "string", description: "Shell command to execute" },
193
+ timeout: {
194
+ type: "integer",
195
+ description: `Timeout in seconds (default: ${BASH_DEFAULT_TIMEOUT}, max: ${BASH_MAX_TIMEOUT})`,
196
+ default: BASH_DEFAULT_TIMEOUT,
197
+ },
198
+ cwd: {
199
+ type: "string",
200
+ description: "Working directory for the command (default: current directory)",
201
+ },
202
+ },
203
+ required: ["command"],
204
+ },
205
+ summaryTemplate: "{agent} is running a shell command",
206
+ };
207
+ const DIFF = {
208
+ name: "diff",
209
+ description: "Show unified diff between two files, or between a file's current content and provided new content.",
210
+ parameters: {
211
+ type: "object",
212
+ properties: {
213
+ file_a: { type: "string", description: "Path to first file" },
214
+ file_b: {
215
+ type: "string",
216
+ description: "Path to second file (optional if content_b is given)",
217
+ default: "",
218
+ },
219
+ content_b: {
220
+ type: "string",
221
+ description: "Content to compare against file_a (optional if file_b is given)",
222
+ default: "",
223
+ },
224
+ },
225
+ required: ["file_a"],
226
+ },
227
+ summaryTemplate: "{agent} is comparing {file_a}",
228
+ };
229
+ const TEST = {
230
+ name: "test",
231
+ description: "Run a test command (e.g. pytest, unittest) and return the result.",
232
+ parameters: {
233
+ type: "object",
234
+ properties: {
235
+ command: {
236
+ type: "string",
237
+ description: "Test command to run (default: 'python -m pytest')",
238
+ default: "python -m pytest",
239
+ },
240
+ timeout: {
241
+ type: "integer",
242
+ description: "Timeout in seconds (default: 60)",
243
+ default: 60,
244
+ },
245
+ },
246
+ required: [],
247
+ },
248
+ summaryTemplate: "{agent} is running tests",
249
+ };
250
+ // ------------------------------------------------------------------
251
+ // Glob tool
252
+ // ------------------------------------------------------------------
253
+ const GLOB_MAX_RESULTS = 200;
254
+ const GLOB_MAX_FILES_SCANNED = 10_000;
255
+ const GLOB_MAX_DEPTH = 10;
256
+ const GLOB = {
257
+ name: "glob",
258
+ description: "Find files by name pattern. Returns matching paths sorted by modification time.",
259
+ parameters: {
260
+ type: "object",
261
+ properties: {
262
+ pattern: {
263
+ type: "string",
264
+ description: "Glob pattern to match (e.g. \"**/*.ts\", \"src/**/*.test.tsx\")",
265
+ },
266
+ path: {
267
+ type: "string",
268
+ description: "Directory to search in (default: current directory)",
269
+ default: ".",
270
+ },
271
+ },
272
+ required: ["pattern"],
273
+ },
274
+ summaryTemplate: "{agent} is finding files matching '{pattern}'",
275
+ };
276
+ // ------------------------------------------------------------------
277
+ // Grep tool (enhanced search)
278
+ // ------------------------------------------------------------------
279
+ const GREP = {
280
+ name: "grep",
281
+ description: "Search file contents using regex. Supports context lines, glob filtering, and multiple output modes.",
282
+ parameters: {
283
+ type: "object",
284
+ properties: {
285
+ pattern: {
286
+ type: "string",
287
+ description: "Regex pattern to search for",
288
+ },
289
+ path: {
290
+ type: "string",
291
+ description: "Directory or file to search in (default: current directory)",
292
+ default: ".",
293
+ },
294
+ glob: {
295
+ type: "string",
296
+ description: "Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\")",
297
+ },
298
+ type: {
299
+ type: "string",
300
+ description: "File type filter by extension (e.g. \"js\", \"py\", \"ts\")",
301
+ },
302
+ output_mode: {
303
+ type: "string",
304
+ enum: ["content", "files_with_matches", "count"],
305
+ description: "Output mode: \"content\" (matching lines with context), " +
306
+ "\"files_with_matches\" (file paths only, default), " +
307
+ "\"count\" (match counts per file)",
308
+ },
309
+ "-A": {
310
+ type: "integer",
311
+ description: "Lines to show after each match (content mode only)",
312
+ },
313
+ "-B": {
314
+ type: "integer",
315
+ description: "Lines to show before each match (content mode only)",
316
+ },
317
+ "-C": {
318
+ type: "integer",
319
+ description: "Lines to show before and after each match (content mode only)",
320
+ },
321
+ "-i": {
322
+ type: "boolean",
323
+ description: "Case insensitive search",
324
+ },
325
+ "-n": {
326
+ type: "boolean",
327
+ description: "Show line numbers (default true for content mode)",
328
+ },
329
+ head_limit: {
330
+ type: "integer",
331
+ description: "Limit output to first N entries",
332
+ },
333
+ },
334
+ required: ["pattern"],
335
+ },
336
+ summaryTemplate: "{agent} is searching for '{pattern}'",
337
+ };
338
+ // ------------------------------------------------------------------
339
+ // Background shell tools (tracked by Session)
340
+ // ------------------------------------------------------------------
341
+ export const BASH_BACKGROUND_TOOL = {
342
+ name: "bash_background",
343
+ description: "Start a background shell command tracked by the Session. " +
344
+ "Use for dev servers, watchers, and long-running commands whose output you want to inspect later.",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ command: { type: "string", description: "Shell command to execute in the background." },
349
+ cwd: { type: "string", description: "Optional working directory for the command." },
350
+ id: {
351
+ type: "string",
352
+ description: "Optional stable shell ID. If omitted, the Session generates one.",
353
+ },
354
+ },
355
+ required: ["command"],
356
+ },
357
+ summaryTemplate: "{agent} is starting a background shell",
358
+ };
359
+ export const BASH_OUTPUT_TOOL = {
360
+ name: "bash_output",
361
+ description: "Read output from a tracked background shell. " +
362
+ "By default, returns unread output since the last bash_output call for that shell. " +
363
+ "Use tail_lines to inspect recent output without advancing the unread cursor.",
364
+ parameters: {
365
+ type: "object",
366
+ properties: {
367
+ id: { type: "string", description: "Tracked shell ID." },
368
+ tail_lines: {
369
+ type: "integer",
370
+ description: "Optional: return the last N lines without advancing unread state.",
371
+ },
372
+ max_chars: {
373
+ type: "integer",
374
+ description: "Optional max characters to return (default 8000).",
375
+ },
376
+ },
377
+ required: ["id"],
378
+ },
379
+ summaryTemplate: "{agent} is reading background shell output",
380
+ };
381
+ export const KILL_SHELL_TOOL = {
382
+ name: "kill_shell",
383
+ description: "Terminate one or more tracked background shells. " +
384
+ "Use when a watcher or dev server is no longer needed, or a command is stuck.",
385
+ parameters: {
386
+ type: "object",
387
+ properties: {
388
+ ids: {
389
+ type: "array",
390
+ items: { type: "string" },
391
+ description: "Tracked shell IDs to terminate.",
392
+ },
393
+ signal: {
394
+ type: "string",
395
+ description: "Optional signal name (default TERM).",
396
+ },
397
+ },
398
+ required: ["ids"],
399
+ },
400
+ summaryTemplate: "{agent} is terminating background shells",
401
+ };
402
+ // ------------------------------------------------------------------
403
+ // Exports: tool lists
404
+ // ------------------------------------------------------------------
405
+ export const BASIC_TOOLS = [
406
+ READ,
407
+ LIST,
408
+ GLOB,
409
+ GREP,
410
+ EDIT,
411
+ WRITE,
412
+ APPLY_PATCH,
413
+ BASH,
414
+ BASH_BACKGROUND_TOOL,
415
+ BASH_OUTPUT_TOOL,
416
+ KILL_SHELL_TOOL,
417
+ DIFF,
418
+ TEST,
419
+ WEB_SEARCH,
420
+ WEB_FETCH,
421
+ ];
422
+ export const BASIC_TOOLS_MAP = Object.fromEntries(BASIC_TOOLS.map((t) => [t.name, t]));
423
+ // ======================================================================
424
+ // Tool executors
425
+ // ======================================================================
426
+ // ------------------------------------------------------------------
427
+ // read_file
428
+ // ------------------------------------------------------------------
429
+ async function toolReadFile(filePath, startLine, endLine, artifactsDir, supportsMultimodal) {
430
+ const sensitiveReason = getSensitiveFileReadReason(filePath);
431
+ if (sensitiveReason) {
432
+ return `ERROR: Access to sensitive file is blocked by default: ${filePath} (${sensitiveReason}).`;
433
+ }
434
+ if (!existsSync(filePath)) {
435
+ return `ERROR: File not found: ${filePath}`;
436
+ }
437
+ let stat;
438
+ try {
439
+ stat = statSync(filePath);
440
+ }
441
+ catch (e) {
442
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
443
+ }
444
+ if (!stat.isFile()) {
445
+ return `ERROR: Not a file: ${filePath}`;
446
+ }
447
+ // --- Image file handling ---
448
+ const [isImage] = classifyFile(filePath);
449
+ if (isImage) {
450
+ if (!supportsMultimodal) {
451
+ return `ERROR: Cannot read image file: current model does not support multimodal input. File: ${filePath}`;
452
+ }
453
+ if (stat.size > READ_MAX_IMAGE_SIZE) {
454
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
455
+ return `ERROR: Image too large (${sizeMB} MB, limit ${READ_MAX_IMAGE_SIZE / 1024 / 1024} MB).`;
456
+ }
457
+ const ext = path.extname(filePath).toLowerCase();
458
+ const mediaType = IMAGE_MEDIA_TYPES[ext] ?? "application/octet-stream";
459
+ try {
460
+ const raw = readFileSync(filePath);
461
+ const b64Data = raw.toString("base64");
462
+ const sizeFmt = stat.size < 1024
463
+ ? `${stat.size} B`
464
+ : stat.size < 1024 * 1024
465
+ ? `${(stat.size / 1024).toFixed(1)} KB`
466
+ : `${(stat.size / (1024 * 1024)).toFixed(1)} MB`;
467
+ const description = `[Image: ${path.basename(filePath)} | ${mediaType} | ${sizeFmt}]`;
468
+ return new ToolResult({
469
+ content: description,
470
+ contentBlocks: [
471
+ { type: "text", text: description },
472
+ {
473
+ type: "image",
474
+ source: {
475
+ type: "base64",
476
+ media_type: mediaType,
477
+ data: b64Data,
478
+ },
479
+ },
480
+ ],
481
+ });
482
+ }
483
+ catch (e) {
484
+ return `ERROR: Failed to read image: ${e instanceof Error ? e.message : String(e)}`;
485
+ }
486
+ }
487
+ if (stat.size > READ_MAX_FILE_SIZE) {
488
+ const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
489
+ return `ERROR: File too large (${sizeMB} MB, limit ${READ_MAX_FILE_SIZE / 1024 / 1024} MB).`;
490
+ }
491
+ const isProjectedDocument = isProjectedDocumentPath(filePath);
492
+ let text;
493
+ let mtimeMs = Math.trunc(stat.mtimeMs);
494
+ let sizeBytes = stat.size;
495
+ let headerPrefix = "";
496
+ try {
497
+ if (isProjectedDocument) {
498
+ const view = await loadProjectedDocumentView(filePath, artifactsDir);
499
+ text = view.text;
500
+ mtimeMs = view.mtimeMs;
501
+ sizeBytes = view.sizeBytes;
502
+ headerPrefix =
503
+ `[Auto-extracted Markdown view of ${path.basename(filePath)} (${projectedDocumentLabel(filePath)} source) | ` +
504
+ `original_path=${filePath}]` + "\n";
505
+ }
506
+ else {
507
+ text = readFileSync(filePath, { encoding: "utf-8" });
508
+ }
509
+ }
510
+ catch (e) {
511
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
512
+ }
513
+ const lines = text.split(/\r?\n/);
514
+ // Keep trailing newline semantics: if file ends with \n the last split
515
+ // element is "" but that represents "no extra line".
516
+ const total = lines.length;
517
+ let start = startLine ?? 1;
518
+ let end = endLine == null || endLine === -1 ? total : endLine;
519
+ if (start < 1)
520
+ return `ERROR: start_line must be >= 1, got ${start}.`;
521
+ if (start > total)
522
+ return `ERROR: start_line ${start} exceeds total lines (${total}).`;
523
+ if (end > total)
524
+ end = total;
525
+ if (end < start)
526
+ return `ERROR: end_line (${end}) < start_line (${start}).`;
527
+ // Apply line limit
528
+ if (end - start + 1 > READ_MAX_LINES) {
529
+ end = start + READ_MAX_LINES - 1;
530
+ }
531
+ let selected = lines.slice(start - 1, end);
532
+ // Apply character limit
533
+ let charCount = 0;
534
+ let truncatedAtLine = null;
535
+ for (let i = 0; i < selected.length; i++) {
536
+ charCount += selected[i].length + 1; // +1 for newline
537
+ if (charCount > READ_MAX_CHARS) {
538
+ selected = selected.slice(0, i);
539
+ truncatedAtLine = start + i; // 1-indexed line that exceeded the limit
540
+ end = start + i - 1; // last fully included line
541
+ break;
542
+ }
543
+ }
544
+ let result = headerPrefix +
545
+ `[Lines ${start}-${end} of ${total} | mtime_ms=${mtimeMs} | size_bytes=${sizeBytes}]\n` +
546
+ selected.join("\n");
547
+ if (truncatedAtLine !== null) {
548
+ result +=
549
+ `\n\n[WARNING: Reached ${READ_MAX_CHARS.toLocaleString()} character limit at line ` +
550
+ `${truncatedAtLine}. Showing lines ${start}-${end} ` +
551
+ `(${end - start + 1} complete lines). ` +
552
+ `Use start_line=${end + 1} to continue reading${isProjectedDocument ? " the extracted Markdown view of the same source path" : ""}.]`;
553
+ }
554
+ else if (end < total) {
555
+ result +=
556
+ `\n\n[Output truncated at ${READ_MAX_LINES} lines. ` +
557
+ `Use start_line=${end + 1} to continue reading${isProjectedDocument ? " the extracted Markdown view of the same source path" : ""}.]`;
558
+ }
559
+ return result;
560
+ }
561
+ // ------------------------------------------------------------------
562
+ // list_dir
563
+ // ------------------------------------------------------------------
564
+ function toolListDir(dirPath = ".") {
565
+ if (!existsSync(dirPath)) {
566
+ return `ERROR: Directory not found: ${dirPath}`;
567
+ }
568
+ const stat = statSync(dirPath);
569
+ if (!stat.isDirectory()) {
570
+ return `ERROR: Not a directory: ${dirPath}`;
571
+ }
572
+ const lines = [];
573
+ function walk(dir, prefix, depth) {
574
+ if (depth > 2)
575
+ return;
576
+ let entries;
577
+ try {
578
+ entries = readdirSync(dir);
579
+ }
580
+ catch {
581
+ return;
582
+ }
583
+ // Sort: directories first, then files, alphabetical
584
+ const withStats = entries
585
+ .filter((name) => !name.startsWith(".") &&
586
+ name !== "node_modules" &&
587
+ name !== "__pycache__")
588
+ .map((name) => {
589
+ const full = path.join(dir, name);
590
+ let isDir = false;
591
+ try {
592
+ isDir = statSync(full).isDirectory();
593
+ }
594
+ catch {
595
+ // skip inaccessible
596
+ }
597
+ return { name, full, isDir };
598
+ })
599
+ .sort((a, b) => {
600
+ if (a.isDir !== b.isDir)
601
+ return a.isDir ? -1 : 1;
602
+ return a.name.localeCompare(b.name);
603
+ });
604
+ for (const entry of withStats) {
605
+ const marker = entry.isDir ? "[DIR] " : "";
606
+ lines.push(`${prefix}${marker}${entry.name}`);
607
+ if (entry.isDir) {
608
+ walk(entry.full, prefix + " ", depth + 1);
609
+ }
610
+ }
611
+ }
612
+ walk(dirPath, "", 0);
613
+ return lines.length > 0 ? lines.join("\n") : "(empty directory)";
614
+ }
615
+ class FileVersionConflictError extends Error {
616
+ constructor(message) {
617
+ super(message);
618
+ this.name = "FileVersionConflictError";
619
+ }
620
+ }
621
+ function getFileVersionSnapshot(filePath) {
622
+ if (!existsSync(filePath))
623
+ return { exists: false };
624
+ const st = statSync(filePath);
625
+ return {
626
+ exists: true,
627
+ mtimeMs: Math.trunc(st.mtimeMs),
628
+ size: st.size,
629
+ ino: typeof st.ino === "number" ? st.ino : undefined,
630
+ dev: typeof st.dev === "number" ? st.dev : undefined,
631
+ mode: st.mode,
632
+ };
633
+ }
634
+ function sameFileVersion(a, b) {
635
+ if (a.exists !== b.exists)
636
+ return false;
637
+ if (!a.exists && !b.exists)
638
+ return true;
639
+ return (a.mtimeMs === b.mtimeMs &&
640
+ a.size === b.size &&
641
+ a.ino === b.ino &&
642
+ a.dev === b.dev);
643
+ }
644
+ function validateExpectedMtime(filePath, expectedMtimeMs, current) {
645
+ if (expectedMtimeMs == null)
646
+ return;
647
+ if (!current.exists) {
648
+ throw new FileVersionConflictError(`File changed since last read (mtime conflict): ${filePath} (file does not exist).`);
649
+ }
650
+ if (current.mtimeMs !== expectedMtimeMs) {
651
+ throw new FileVersionConflictError(`File changed since last read (mtime conflict): ${filePath} ` +
652
+ `(expected ${expectedMtimeMs}, current ${current.mtimeMs}).`);
653
+ }
654
+ }
655
+ function fileWriteLockKey(filePath) {
656
+ try {
657
+ return realpathSync(filePath);
658
+ }
659
+ catch {
660
+ return path.resolve(filePath);
661
+ }
662
+ }
663
+ async function withFileWriteLock(filePath, fn) {
664
+ const key = fileWriteLockKey(filePath);
665
+ const previous = FILE_WRITE_LOCKS.get(key) ?? Promise.resolve();
666
+ let release;
667
+ const current = new Promise((resolve) => {
668
+ release = resolve;
669
+ });
670
+ const chain = previous.then(() => current);
671
+ FILE_WRITE_LOCKS.set(key, chain);
672
+ await previous;
673
+ try {
674
+ return await fn();
675
+ }
676
+ finally {
677
+ release();
678
+ if (FILE_WRITE_LOCKS.get(key) === chain) {
679
+ FILE_WRITE_LOCKS.delete(key);
680
+ }
681
+ }
682
+ }
683
+ // ------------------------------------------------------------------
684
+ // edit_file
685
+ // ------------------------------------------------------------------
686
+ async function toolEditFile(filePath, oldStr, newStr, expectedMtimeMs) {
687
+ return withFileWriteLock(filePath, async () => {
688
+ if (!existsSync(filePath)) {
689
+ return `ERROR: File not found: ${filePath}`;
690
+ }
691
+ let initialVersion;
692
+ try {
693
+ initialVersion = getFileVersionSnapshot(filePath);
694
+ validateExpectedMtime(filePath, expectedMtimeMs, initialVersion);
695
+ }
696
+ catch (e) {
697
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
698
+ }
699
+ let content;
700
+ try {
701
+ content = readFileSync(filePath, { encoding: "utf-8" });
702
+ }
703
+ catch (e) {
704
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
705
+ }
706
+ const count = content.split(oldStr).length - 1;
707
+ if (count === 0) {
708
+ return "ERROR: old_str not found in file.";
709
+ }
710
+ if (count > 1) {
711
+ return `ERROR: old_str appears ${count} times (must be unique).`;
712
+ }
713
+ const newContent = content.replace(oldStr, newStr);
714
+ const diffPreview = buildUnifiedDiffPreview(simpleUnifiedDiff(content.split("\n"), newContent.split("\n"), filePath, filePath));
715
+ try {
716
+ await atomicWriteTextFile(filePath, newContent, initialVersion.mode, initialVersion);
717
+ }
718
+ catch (e) {
719
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
720
+ }
721
+ return new ToolResult({
722
+ content: "OK: File edited successfully.",
723
+ metadata: {
724
+ path: filePath,
725
+ tui_preview: {
726
+ kind: "diff",
727
+ text: diffPreview.text,
728
+ truncated: diffPreview.truncated,
729
+ },
730
+ },
731
+ });
732
+ });
733
+ }
734
+ // ------------------------------------------------------------------
735
+ // write_file
736
+ // ------------------------------------------------------------------
737
+ async function toolWriteFile(filePath, content, expectedMtimeMs) {
738
+ return withFileWriteLock(filePath, async () => {
739
+ try {
740
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
741
+ const initialVersion = getFileVersionSnapshot(filePath);
742
+ validateExpectedMtime(filePath, expectedMtimeMs, initialVersion);
743
+ const mode = initialVersion.mode;
744
+ const before = initialVersion.exists
745
+ ? readFileSync(filePath, { encoding: "utf-8" })
746
+ : "";
747
+ const beforeLines = before.length > 0 ? before.split("\n") : [];
748
+ const afterLines = content.length > 0 ? content.split("\n") : [];
749
+ const diffPreview = buildUnifiedDiffPreview(simpleUnifiedDiff(beforeLines, afterLines, filePath, filePath));
750
+ await atomicWriteTextFile(filePath, content, mode, initialVersion);
751
+ return new ToolResult({
752
+ content: `OK: Wrote ${content.length} characters to ${filePath}`,
753
+ metadata: {
754
+ path: filePath,
755
+ tui_preview: {
756
+ kind: "diff",
757
+ text: diffPreview.text,
758
+ truncated: diffPreview.truncated,
759
+ },
760
+ },
761
+ });
762
+ }
763
+ catch (e) {
764
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
765
+ }
766
+ });
767
+ }
768
+ async function atomicWriteTextFile(filePath, content, mode, expectedVersion) {
769
+ const dir = path.dirname(filePath);
770
+ const base = path.basename(filePath);
771
+ const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${randomUUID()}`);
772
+ let tmpExists = false;
773
+ try {
774
+ await fs.writeFile(tmpPath, content, { encoding: "utf-8" });
775
+ tmpExists = true;
776
+ if (mode !== undefined) {
777
+ try {
778
+ await fs.chmod(tmpPath, mode);
779
+ }
780
+ catch {
781
+ // Best-effort permission preservation
782
+ }
783
+ }
784
+ if (expectedVersion) {
785
+ const currentVersion = getFileVersionSnapshot(filePath);
786
+ if (!sameFileVersion(expectedVersion, currentVersion)) {
787
+ throw new FileVersionConflictError(`File changed during write (mtime conflict): ${filePath}. Please re-read and retry.`);
788
+ }
789
+ }
790
+ await fs.rename(tmpPath, filePath);
791
+ tmpExists = false;
792
+ }
793
+ finally {
794
+ if (tmpExists) {
795
+ try {
796
+ await fs.unlink(tmpPath);
797
+ }
798
+ catch {
799
+ // ignore cleanup failure
800
+ }
801
+ }
802
+ }
803
+ }
804
+ function parsePatchBodyLines(lines, startIdx) {
805
+ const contentLines = [];
806
+ let i = startIdx;
807
+ while (i < lines.length && !lines[i].startsWith("***")) {
808
+ const line = lines[i];
809
+ if (line.startsWith("+")) {
810
+ contentLines.push(line.slice(1));
811
+ }
812
+ else if (line.trim() !== "") {
813
+ throw new Error(`Invalid patch line in add/append block: '${line}'`);
814
+ }
815
+ i += 1;
816
+ }
817
+ return { contents: contentLines.join("\n"), nextIdx: i };
818
+ }
819
+ function parsePatchUpdateChunks(lines, startIdx) {
820
+ const chunks = [];
821
+ let i = startIdx;
822
+ while (i < lines.length && !lines[i].startsWith("***")) {
823
+ if (!lines[i].startsWith("@@")) {
824
+ if (!lines[i].trim()) {
825
+ i += 1;
826
+ continue;
827
+ }
828
+ throw new Error(`Invalid patch chunk header: '${lines[i]}'`);
829
+ }
830
+ const changeContext = lines[i].slice(2).trim() || undefined;
831
+ i += 1;
832
+ const oldLines = [];
833
+ const newLines = [];
834
+ while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
835
+ const line = lines[i];
836
+ if (line.startsWith(" ")) {
837
+ const content = line.slice(1);
838
+ oldLines.push(content);
839
+ newLines.push(content);
840
+ }
841
+ else if (line.startsWith("-")) {
842
+ oldLines.push(line.slice(1));
843
+ }
844
+ else if (line.startsWith("+")) {
845
+ newLines.push(line.slice(1));
846
+ }
847
+ else if (line.trim() !== "") {
848
+ throw new Error(`Invalid patch change line: '${line}'`);
849
+ }
850
+ i += 1;
851
+ }
852
+ if (oldLines.length === 0 && newLines.length === 0) {
853
+ throw new Error("Empty update chunk is not allowed.");
854
+ }
855
+ chunks.push({ oldLines, newLines, changeContext });
856
+ }
857
+ return { chunks, nextIdx: i };
858
+ }
859
+ function parseApplyPatchText(patchText) {
860
+ const trimmed = patchText.trim();
861
+ if (!trimmed)
862
+ throw new Error("patchText is required.");
863
+ const lines = trimmed.split("\n");
864
+ const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
865
+ const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
866
+ if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
867
+ throw new Error("Invalid patch format: missing Begin/End markers.");
868
+ }
869
+ const ops = [];
870
+ let i = beginIdx + 1;
871
+ while (i < endIdx) {
872
+ const line = lines[i];
873
+ if (!line.trim()) {
874
+ i += 1;
875
+ continue;
876
+ }
877
+ if (line.startsWith("*** Add File:")) {
878
+ const requestedPath = line.slice("*** Add File:".length).trim();
879
+ if (!requestedPath)
880
+ throw new Error("Add File is missing a path.");
881
+ const { contents, nextIdx } = parsePatchBodyLines(lines, i + 1);
882
+ ops.push({ type: "add", path: requestedPath, contents });
883
+ i = nextIdx;
884
+ continue;
885
+ }
886
+ if (line.startsWith("*** Append File:")) {
887
+ const requestedPath = line.slice("*** Append File:".length).trim();
888
+ if (!requestedPath)
889
+ throw new Error("Append File is missing a path.");
890
+ const { contents, nextIdx } = parsePatchBodyLines(lines, i + 1);
891
+ ops.push({ type: "append", path: requestedPath, contents });
892
+ i = nextIdx;
893
+ continue;
894
+ }
895
+ if (line.startsWith("*** Delete File:")) {
896
+ const requestedPath = line.slice("*** Delete File:".length).trim();
897
+ if (!requestedPath)
898
+ throw new Error("Delete File is missing a path.");
899
+ ops.push({ type: "delete", path: requestedPath });
900
+ i += 1;
901
+ continue;
902
+ }
903
+ if (line.startsWith("*** Update File:")) {
904
+ const requestedPath = line.slice("*** Update File:".length).trim();
905
+ if (!requestedPath)
906
+ throw new Error("Update File is missing a path.");
907
+ const { chunks, nextIdx } = parsePatchUpdateChunks(lines, i + 1);
908
+ if (!chunks.length) {
909
+ throw new Error(`Update File '${requestedPath}' does not contain any chunks.`);
910
+ }
911
+ ops.push({ type: "update", path: requestedPath, chunks });
912
+ i = nextIdx;
913
+ continue;
914
+ }
915
+ throw new Error(`Invalid patch directive: '${line}'`);
916
+ }
917
+ if (!ops.length) {
918
+ throw new Error("patch rejected: empty patch");
919
+ }
920
+ return ops;
921
+ }
922
+ function seekSequence(lines, needle, startIdx) {
923
+ if (needle.length === 0)
924
+ return startIdx;
925
+ outer: for (let i = Math.max(0, startIdx); i <= lines.length - needle.length; i += 1) {
926
+ for (let j = 0; j < needle.length; j += 1) {
927
+ if (lines[i + j] !== needle[j])
928
+ continue outer;
929
+ }
930
+ return i;
931
+ }
932
+ return -1;
933
+ }
934
+ function applyUpdateChunksToContent(filePath, originalContent, chunks) {
935
+ let lines = originalContent.split("\n");
936
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
937
+ lines = lines.slice(0, -1);
938
+ }
939
+ let cursor = 0;
940
+ for (const chunk of chunks) {
941
+ const oldSeq = chunk.oldLines;
942
+ let matchIdx = -1;
943
+ if (chunk.changeContext) {
944
+ for (let i = cursor; i < lines.length; i += 1) {
945
+ if (lines[i] !== chunk.changeContext)
946
+ continue;
947
+ const candidate = seekSequence(lines, oldSeq, i);
948
+ if (candidate !== -1) {
949
+ matchIdx = candidate;
950
+ break;
951
+ }
952
+ }
953
+ }
954
+ else {
955
+ matchIdx = seekSequence(lines, oldSeq, cursor);
956
+ if (matchIdx === -1) {
957
+ matchIdx = seekSequence(lines, oldSeq, 0);
958
+ }
959
+ }
960
+ if (matchIdx === -1) {
961
+ const detail = chunk.changeContext
962
+ ? `context '${chunk.changeContext}'`
963
+ : oldSeq.length > 0
964
+ ? `sequence '${oldSeq[0]}'`
965
+ : "target location";
966
+ throw new Error(`Failed to match patch chunk in ${filePath}: ${detail}`);
967
+ }
968
+ lines.splice(matchIdx, oldSeq.length, ...chunk.newLines);
969
+ cursor = matchIdx + chunk.newLines.length;
970
+ }
971
+ let nextContent = lines.join("\n");
972
+ if (nextContent && !nextContent.endsWith("\n")) {
973
+ nextContent += "\n";
974
+ }
975
+ return nextContent;
976
+ }
977
+ function displayRelativePath(root, filePath) {
978
+ const rel = path.relative(root, filePath) || path.basename(filePath);
979
+ return rel.split(path.sep).join("/");
980
+ }
981
+ async function toolApplyPatch(patchText, ctx) {
982
+ const ops = parseApplyPatchText(patchText);
983
+ const root = toolRoot(ctx);
984
+ const prepared = [];
985
+ for (const op of ops) {
986
+ if (op.type === "add") {
987
+ const filePath = scopedPath(op.path, "write", ctx, { allowCreate: true, expectFile: true });
988
+ if (existsSync(filePath)) {
989
+ throw new Error(`apply_patch verification failed: File already exists: ${displayRelativePath(root, filePath)}`);
990
+ }
991
+ prepared.push({
992
+ type: "add",
993
+ requestedPath: op.path,
994
+ filePath,
995
+ before: "",
996
+ after: op.contents,
997
+ });
998
+ continue;
999
+ }
1000
+ if (op.type === "append") {
1001
+ const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
1002
+ const before = readFileSync(filePath, "utf-8");
1003
+ const separator = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
1004
+ let after = before + separator + op.contents;
1005
+ if (after && !after.endsWith("\n"))
1006
+ after += "\n";
1007
+ prepared.push({
1008
+ type: "append",
1009
+ requestedPath: op.path,
1010
+ filePath,
1011
+ before,
1012
+ after,
1013
+ mode: getFileVersionSnapshot(filePath).mode,
1014
+ });
1015
+ continue;
1016
+ }
1017
+ if (op.type === "delete") {
1018
+ const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
1019
+ prepared.push({
1020
+ type: "delete",
1021
+ requestedPath: op.path,
1022
+ filePath,
1023
+ before: readFileSync(filePath, "utf-8"),
1024
+ after: null,
1025
+ mode: getFileVersionSnapshot(filePath).mode,
1026
+ });
1027
+ continue;
1028
+ }
1029
+ const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
1030
+ const before = readFileSync(filePath, "utf-8");
1031
+ const after = applyUpdateChunksToContent(filePath, before, op.chunks);
1032
+ prepared.push({
1033
+ type: "update",
1034
+ requestedPath: op.path,
1035
+ filePath,
1036
+ before,
1037
+ after,
1038
+ mode: getFileVersionSnapshot(filePath).mode,
1039
+ });
1040
+ }
1041
+ const previewDiffs = [];
1042
+ for (const change of prepared) {
1043
+ const beforeLines = change.before.split("\n");
1044
+ const afterLines = (change.after ?? "").split("\n");
1045
+ previewDiffs.push(simpleUnifiedDiff(beforeLines, afterLines, change.filePath, change.filePath));
1046
+ }
1047
+ const diffPreview = buildUnifiedDiffPreview(previewDiffs.join("\n"));
1048
+ for (const change of prepared) {
1049
+ if (change.after === null) {
1050
+ await fs.unlink(change.filePath);
1051
+ continue;
1052
+ }
1053
+ await fs.mkdir(path.dirname(change.filePath), { recursive: true });
1054
+ const expectedVersion = change.type === "add"
1055
+ ? undefined
1056
+ : getFileVersionSnapshot(change.filePath);
1057
+ await atomicWriteTextFile(change.filePath, change.after, change.mode, expectedVersion);
1058
+ }
1059
+ const lines = ["Success. Updated the following files:"];
1060
+ for (const change of prepared) {
1061
+ const kind = change.type === "add"
1062
+ ? "A"
1063
+ : change.type === "delete"
1064
+ ? "D"
1065
+ : "M";
1066
+ lines.push(`${kind} ${displayRelativePath(root, change.filePath)}`);
1067
+ }
1068
+ return new ToolResult({
1069
+ content: lines.join("\n"),
1070
+ metadata: {
1071
+ paths: prepared.map((change) => change.filePath),
1072
+ tui_preview: {
1073
+ kind: "diff",
1074
+ text: diffPreview.text,
1075
+ truncated: diffPreview.truncated,
1076
+ },
1077
+ },
1078
+ });
1079
+ }
1080
+ // ------------------------------------------------------------------
1081
+ // bash
1082
+ // ------------------------------------------------------------------
1083
+ function truncateOutput(text, limit) {
1084
+ if (text.length <= limit)
1085
+ return text;
1086
+ const half = Math.floor(limit / 2);
1087
+ const omitted = text.length - limit;
1088
+ return (text.slice(0, half) +
1089
+ `\n\n... [truncated ${omitted.toLocaleString()} chars] ...\n\n` +
1090
+ text.slice(-half));
1091
+ }
1092
+ export function buildBashEnv() {
1093
+ const env = {};
1094
+ for (const [key, value] of Object.entries(process.env)) {
1095
+ if (value == null)
1096
+ continue;
1097
+ if (BASH_ENV_ALLOWLIST.has(key) || key.startsWith("LC_")) {
1098
+ env[key] = value;
1099
+ }
1100
+ }
1101
+ // Keep a usable PATH even if parent PATH is missing.
1102
+ if (!env["PATH"]) {
1103
+ env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin";
1104
+ }
1105
+ return env;
1106
+ }
1107
+ function toolBash(command, timeout = BASH_DEFAULT_TIMEOUT, cwd = "") {
1108
+ // Enforce timeout bounds
1109
+ if (typeof timeout !== "number" || timeout < 1) {
1110
+ timeout = BASH_DEFAULT_TIMEOUT;
1111
+ }
1112
+ timeout = Math.min(timeout, BASH_MAX_TIMEOUT);
1113
+ // Resolve working directory
1114
+ let runCwd;
1115
+ if (cwd) {
1116
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
1117
+ return `ERROR: Working directory does not exist or is not a directory: ${cwd}`;
1118
+ }
1119
+ runCwd = cwd;
1120
+ }
1121
+ const result = spawnSync("sh", ["-c", command], {
1122
+ cwd: runCwd,
1123
+ timeout: timeout * 1000,
1124
+ encoding: "utf-8",
1125
+ maxBuffer: 10 * 1024 * 1024, // 10 MB buffer
1126
+ env: buildBashEnv(),
1127
+ killSignal: BASH_TIMEOUT_KILL_SIGNAL,
1128
+ });
1129
+ if (result.error) {
1130
+ if (result.error.code === "ETIMEDOUT" ||
1131
+ result.signal === "SIGTERM" ||
1132
+ result.signal === BASH_TIMEOUT_KILL_SIGNAL) {
1133
+ return (`ERROR: Command timed out after ${timeout}s (max allowed: ${BASH_MAX_TIMEOUT}s). ` +
1134
+ `Shell process was terminated (${BASH_TIMEOUT_KILL_SIGNAL}); child-process tree termination is best-effort.`);
1135
+ }
1136
+ return `ERROR: ${result.error.message}`;
1137
+ }
1138
+ const parts = [];
1139
+ if (result.stdout) {
1140
+ parts.push(`STDOUT:\n${truncateOutput(result.stdout, BASH_MAX_OUTPUT_CHARS)}`);
1141
+ }
1142
+ if (result.stderr) {
1143
+ parts.push(`STDERR:\n${truncateOutput(result.stderr, BASH_MAX_OUTPUT_CHARS)}`);
1144
+ }
1145
+ parts.push(`EXIT CODE: ${result.status ?? 1}`);
1146
+ return parts.join("\n");
1147
+ }
1148
+ // ------------------------------------------------------------------
1149
+ // diff
1150
+ // ------------------------------------------------------------------
1151
+ function toolDiff(fileA, fileB = "", contentB = "", contentBProvided = false) {
1152
+ const sensitiveA = getSensitiveFileReadReason(fileA);
1153
+ if (sensitiveA) {
1154
+ return `ERROR: Access to sensitive file is blocked by default: ${fileA} (${sensitiveA}).`;
1155
+ }
1156
+ if (!existsSync(fileA)) {
1157
+ return `ERROR: File not found: ${fileA}`;
1158
+ }
1159
+ let linesA;
1160
+ try {
1161
+ linesA = readFileSync(fileA, { encoding: "utf-8" }).split("\n");
1162
+ }
1163
+ catch (e) {
1164
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
1165
+ }
1166
+ let linesB;
1167
+ let labelB;
1168
+ if (contentBProvided) {
1169
+ linesB = contentB.split("\n");
1170
+ labelB = "(provided content)";
1171
+ }
1172
+ else if (fileB) {
1173
+ const sensitiveB = getSensitiveFileReadReason(fileB);
1174
+ if (sensitiveB) {
1175
+ return `ERROR: Access to sensitive file is blocked by default: ${fileB} (${sensitiveB}).`;
1176
+ }
1177
+ if (!existsSync(fileB)) {
1178
+ return `ERROR: File not found: ${fileB}`;
1179
+ }
1180
+ try {
1181
+ linesB = readFileSync(fileB, { encoding: "utf-8" }).split("\n");
1182
+ }
1183
+ catch (e) {
1184
+ return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
1185
+ }
1186
+ labelB = fileB;
1187
+ }
1188
+ else {
1189
+ return "ERROR: Provide either file_b or content_b.";
1190
+ }
1191
+ // Simple unified diff implementation
1192
+ const result = simpleUnifiedDiff(linesA, linesB, fileA, labelB);
1193
+ return result || "No differences found.";
1194
+ }
1195
+ function buildUnifiedDiffPreview(diff, maxLines = 80, maxChars = 8_000) {
1196
+ if (!diff) {
1197
+ return { text: "(No textual changes.)", truncated: false };
1198
+ }
1199
+ const parsedLines = [];
1200
+ let oldLine = 0;
1201
+ let newLine = 0;
1202
+ for (const raw of diff.split("\n")) {
1203
+ if (raw.startsWith("@@")) {
1204
+ const match = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1205
+ if (match) {
1206
+ oldLine = parseInt(match[1], 10);
1207
+ newLine = parseInt(match[2], 10);
1208
+ }
1209
+ parsedLines.push({ raw });
1210
+ continue;
1211
+ }
1212
+ if (raw.startsWith("--- ") || raw.startsWith("+++ ")) {
1213
+ parsedLines.push({ raw });
1214
+ continue;
1215
+ }
1216
+ if (raw.startsWith("-")) {
1217
+ parsedLines.push({ raw, oldLine });
1218
+ oldLine += 1;
1219
+ continue;
1220
+ }
1221
+ if (raw.startsWith("+")) {
1222
+ parsedLines.push({ raw, newLine });
1223
+ newLine += 1;
1224
+ continue;
1225
+ }
1226
+ if (raw.startsWith(" ")) {
1227
+ parsedLines.push({ raw, oldLine, newLine });
1228
+ oldLine += 1;
1229
+ newLine += 1;
1230
+ continue;
1231
+ }
1232
+ parsedLines.push({ raw });
1233
+ }
1234
+ const displayLineFor = (line) => {
1235
+ if (line.raw.startsWith("-"))
1236
+ return line.oldLine;
1237
+ if (line.raw.startsWith("+"))
1238
+ return line.newLine;
1239
+ if (line.raw.startsWith(" "))
1240
+ return line.newLine;
1241
+ return undefined;
1242
+ };
1243
+ const maxLineNumber = parsedLines.reduce((max, line) => {
1244
+ return Math.max(max, displayLineFor(line) ?? 0);
1245
+ }, 0);
1246
+ const numberWidth = Math.max(String(maxLineNumber || 0).length, 2);
1247
+ const formatLine = (line) => {
1248
+ const displayLine = displayLineFor(line);
1249
+ const lineCol = displayLine == null ? "".padStart(numberWidth, " ") : String(displayLine).padStart(numberWidth, " ");
1250
+ return `${lineCol} | ${line.raw}`;
1251
+ };
1252
+ let previewLines = parsedLines;
1253
+ let truncated = false;
1254
+ if (previewLines.length > 60) {
1255
+ const omitted = previewLines.length - 50;
1256
+ previewLines = [
1257
+ ...previewLines.slice(0, 25),
1258
+ { raw: `... [${omitted} diff lines omitted] ...` },
1259
+ ...previewLines.slice(-25),
1260
+ ];
1261
+ truncated = true;
1262
+ }
1263
+ if (previewLines.length > maxLines) {
1264
+ previewLines = previewLines.slice(0, maxLines);
1265
+ truncated = true;
1266
+ }
1267
+ let text = previewLines.map(formatLine).join("\n");
1268
+ if (text.length > maxChars) {
1269
+ text = text.slice(0, maxChars);
1270
+ const lastNewline = text.lastIndexOf("\n");
1271
+ if (lastNewline !== -1) {
1272
+ text = text.slice(0, lastNewline);
1273
+ }
1274
+ truncated = true;
1275
+ }
1276
+ if (truncated && !text.includes("diff preview truncated")) {
1277
+ text += `\n${"".padStart(numberWidth)} | ... [diff preview truncated]`;
1278
+ }
1279
+ return { text, truncated };
1280
+ }
1281
+ /**
1282
+ * Minimal unified diff: generates a unified diff string from two line arrays.
1283
+ */
1284
+ function simpleUnifiedDiff(a, b, labelA, labelB) {
1285
+ // Use a simple LCS-based approach
1286
+ const n = a.length;
1287
+ const m = b.length;
1288
+ // For very large files, fall back to a simpler comparison
1289
+ if (n * m > 10_000_000) {
1290
+ // Too large for full LCS, just show stats
1291
+ return (`--- ${labelA}\n+++ ${labelB}\n` +
1292
+ `(Files differ: ${n} lines vs ${m} lines, diff too large to compute)`);
1293
+ }
1294
+ // Build LCS table
1295
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
1296
+ for (let i = 1; i <= n; i++) {
1297
+ for (let j = 1; j <= m; j++) {
1298
+ if (a[i - 1] === b[j - 1]) {
1299
+ dp[i][j] = dp[i - 1][j - 1] + 1;
1300
+ }
1301
+ else {
1302
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
1303
+ }
1304
+ }
1305
+ }
1306
+ // Backtrack to find edit script
1307
+ const ops = [];
1308
+ let i = n;
1309
+ let j = m;
1310
+ while (i > 0 || j > 0) {
1311
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
1312
+ ops.push({ type: "equal", line: a[i - 1] });
1313
+ i--;
1314
+ j--;
1315
+ }
1316
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
1317
+ ops.push({ type: "insert", line: b[j - 1] });
1318
+ j--;
1319
+ }
1320
+ else {
1321
+ ops.push({ type: "delete", line: a[i - 1] });
1322
+ i--;
1323
+ }
1324
+ }
1325
+ ops.reverse();
1326
+ // Group into hunks with context
1327
+ const contextLines = 3;
1328
+ const hunks = [];
1329
+ let hunkStart = -1;
1330
+ let hunkLines = [];
1331
+ let aLine = 0;
1332
+ let bLine = 0;
1333
+ let aStart = 0;
1334
+ let bStart = 0;
1335
+ let aCount = 0;
1336
+ let bCount = 0;
1337
+ let lastChangeIdx = -contextLines - 1;
1338
+ function flushHunk() {
1339
+ if (hunkLines.length > 0) {
1340
+ hunks.push(`@@ -${aStart + 1},${aCount} +${bStart + 1},${bCount} @@\n` +
1341
+ hunkLines.join("\n"));
1342
+ hunkLines = [];
1343
+ }
1344
+ }
1345
+ for (let idx = 0; idx < ops.length; idx++) {
1346
+ const op = ops[idx];
1347
+ const isChange = op.type !== "equal";
1348
+ if (isChange) {
1349
+ if (hunkStart === -1 || idx - lastChangeIdx > contextLines * 2) {
1350
+ // Start a new hunk
1351
+ flushHunk();
1352
+ hunkStart = idx;
1353
+ aStart = aLine;
1354
+ bStart = bLine;
1355
+ aCount = 0;
1356
+ bCount = 0;
1357
+ // Add leading context
1358
+ const ctxStart = Math.max(0, idx - contextLines);
1359
+ // We need to recount from ctxStart -- but for simplicity, just
1360
+ // include context from current position
1361
+ }
1362
+ lastChangeIdx = idx;
1363
+ }
1364
+ if (hunkStart !== -1 && idx - lastChangeIdx <= contextLines) {
1365
+ if (op.type === "equal") {
1366
+ hunkLines.push(` ${op.line}`);
1367
+ aCount++;
1368
+ bCount++;
1369
+ }
1370
+ else if (op.type === "delete") {
1371
+ hunkLines.push(`-${op.line}`);
1372
+ aCount++;
1373
+ }
1374
+ else {
1375
+ hunkLines.push(`+${op.line}`);
1376
+ bCount++;
1377
+ }
1378
+ }
1379
+ if (op.type === "equal" || op.type === "delete")
1380
+ aLine++;
1381
+ if (op.type === "equal" || op.type === "insert")
1382
+ bLine++;
1383
+ }
1384
+ flushHunk();
1385
+ if (hunks.length === 0)
1386
+ return "";
1387
+ return `--- ${labelA}\n+++ ${labelB}\n${hunks.join("\n")}`;
1388
+ }
1389
+ // ------------------------------------------------------------------
1390
+ // test
1391
+ // ------------------------------------------------------------------
1392
+ function toolTest(command = "python -m pytest", timeout = 60) {
1393
+ return toolBash(command, timeout);
1394
+ }
1395
+ class ToolArgValidationError extends Error {
1396
+ toolName;
1397
+ field;
1398
+ constructor(toolName, field, message) {
1399
+ super(message);
1400
+ this.name = "ToolArgValidationError";
1401
+ this.toolName = toolName;
1402
+ this.field = field;
1403
+ }
1404
+ }
1405
+ function toolRoot(ctx) {
1406
+ return path.resolve(ctx?.projectRoot ?? process.cwd());
1407
+ }
1408
+ function formatToolError(toolName, err) {
1409
+ if (err instanceof ToolArgValidationError) {
1410
+ return `ERROR: Invalid arguments for ${toolName}: ${err.message}`;
1411
+ }
1412
+ if (err instanceof SafePathError) {
1413
+ const p = err.details.resolvedPath || err.details.requestedPath;
1414
+ switch (err.code) {
1415
+ case "PATH_OUTSIDE_SCOPE":
1416
+ return `ERROR: ${toolName} path is outside the project root boundary: ${err.details.requestedPath}`;
1417
+ case "PATH_SYMLINK_ESCAPES_SCOPE":
1418
+ return `ERROR: ${toolName} path escapes the project root via a symbolic link: ${err.details.requestedPath}`;
1419
+ case "PATH_NOT_FOUND":
1420
+ return `ERROR: Path not found: ${p}`;
1421
+ case "PATH_NOT_FILE":
1422
+ return `ERROR: Not a file: ${p}`;
1423
+ case "PATH_NOT_DIRECTORY":
1424
+ return `ERROR: Not a directory: ${p}`;
1425
+ case "PATH_INVALID_INPUT":
1426
+ return `ERROR: ${err.message}`;
1427
+ default:
1428
+ return `ERROR: ${err.message}`;
1429
+ }
1430
+ }
1431
+ return `ERROR: ${err instanceof Error ? err.message : String(err)}`;
1432
+ }
1433
+ function expectArgsObject(toolName, args) {
1434
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
1435
+ throw new ToolArgValidationError(toolName, "(root)", "arguments must be an object.");
1436
+ }
1437
+ return args;
1438
+ }
1439
+ function requiredStringArg(toolName, args, key, opts) {
1440
+ const v = args[key];
1441
+ if (typeof v !== "string") {
1442
+ throw new ToolArgValidationError(toolName, key, `'${key}' must be a string.`);
1443
+ }
1444
+ if (opts?.nonEmpty && !v.trim()) {
1445
+ throw new ToolArgValidationError(toolName, key, `'${key}' must be a non-empty string.`);
1446
+ }
1447
+ if (opts?.maxLen !== undefined && v.length > opts.maxLen) {
1448
+ throw new ToolArgValidationError(toolName, key, `'${key}' exceeds max length (${opts.maxLen}).`);
1449
+ }
1450
+ return v;
1451
+ }
1452
+ function optionalStringArg(toolName, args, key, fallback) {
1453
+ const v = args[key];
1454
+ if (v == null)
1455
+ return fallback;
1456
+ if (typeof v !== "string") {
1457
+ throw new ToolArgValidationError(toolName, key, `'${key}' must be a string.`);
1458
+ }
1459
+ return v;
1460
+ }
1461
+ function optionalIntegerArg(toolName, args, key) {
1462
+ const v = args[key];
1463
+ if (v == null)
1464
+ return undefined;
1465
+ if (typeof v !== "number" || !Number.isFinite(v) || !Number.isInteger(v)) {
1466
+ throw new ToolArgValidationError(toolName, key, `'${key}' must be an integer.`);
1467
+ }
1468
+ return v;
1469
+ }
1470
+ function scopedPath(requestedPath, accessKind, ctx, opts) {
1471
+ const baseDir = toolRoot(ctx);
1472
+ const attempt = (scopeBaseDir) => safePath({
1473
+ baseDir: scopeBaseDir,
1474
+ requestedPath,
1475
+ cwd: baseDir,
1476
+ accessKind,
1477
+ mustExist: opts.mustExist,
1478
+ allowCreate: opts.allowCreate,
1479
+ expectFile: opts.expectFile,
1480
+ expectDirectory: opts.expectDirectory,
1481
+ }).safePath;
1482
+ try {
1483
+ return attempt(baseDir);
1484
+ }
1485
+ catch (err) {
1486
+ if (!(err instanceof SafePathError))
1487
+ throw err;
1488
+ if (err.code !== "PATH_OUTSIDE_SCOPE" && err.code !== "PATH_SYMLINK_ESCAPES_SCOPE") {
1489
+ throw err;
1490
+ }
1491
+ const allowlist = ctx?.externalPathAllowlist ?? [];
1492
+ for (const allowedRoot of allowlist) {
1493
+ try {
1494
+ return attempt(allowedRoot);
1495
+ }
1496
+ catch (inner) {
1497
+ if (inner instanceof SafePathError &&
1498
+ (inner.code === "PATH_OUTSIDE_SCOPE" || inner.code === "PATH_SYMLINK_ESCAPES_SCOPE")) {
1499
+ continue;
1500
+ }
1501
+ throw inner;
1502
+ }
1503
+ }
1504
+ throw err;
1505
+ }
1506
+ }
1507
+ // ------------------------------------------------------------------
1508
+ // glob executor
1509
+ // ------------------------------------------------------------------
1510
+ /**
1511
+ * Convert a simple glob pattern to a RegExp.
1512
+ * Supports: `*` (any non-slash), `**` (any including slash), `?` (single char),
1513
+ * `{a,b}` (alternatives), and literal characters.
1514
+ */
1515
+ function globToRegex(pattern) {
1516
+ let re = "^";
1517
+ let i = 0;
1518
+ while (i < pattern.length) {
1519
+ const ch = pattern[i];
1520
+ if (ch === "*") {
1521
+ if (pattern[i + 1] === "*") {
1522
+ // ** matches anything including slashes
1523
+ if (pattern[i + 2] === "/") {
1524
+ re += "(?:.*/)?"; // **/ matches zero or more directories
1525
+ i += 3;
1526
+ }
1527
+ else {
1528
+ re += ".*";
1529
+ i += 2;
1530
+ }
1531
+ }
1532
+ else {
1533
+ re += "[^/]*";
1534
+ i++;
1535
+ }
1536
+ }
1537
+ else if (ch === "?") {
1538
+ re += "[^/]";
1539
+ i++;
1540
+ }
1541
+ else if (ch === "{") {
1542
+ const close = pattern.indexOf("}", i);
1543
+ if (close > i) {
1544
+ const alts = pattern.slice(i + 1, close).split(",").map(a => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
1545
+ re += `(?:${alts})`;
1546
+ i = close + 1;
1547
+ }
1548
+ else {
1549
+ re += "\\{";
1550
+ i++;
1551
+ }
1552
+ }
1553
+ else if (".+^$|()[]\\".includes(ch)) {
1554
+ re += "\\" + ch;
1555
+ i++;
1556
+ }
1557
+ else {
1558
+ re += ch;
1559
+ i++;
1560
+ }
1561
+ }
1562
+ re += "$";
1563
+ return new RegExp(re);
1564
+ }
1565
+ const GLOB_SKIP_DIRS = new Set([
1566
+ ".git", "node_modules", "__pycache__", ".next", ".nuxt",
1567
+ "dist", ".tox", ".mypy_cache", ".pytest_cache", ".venv", "venv",
1568
+ ]);
1569
+ function toolGlob(pattern, searchPath) {
1570
+ if (!existsSync(searchPath)) {
1571
+ return `ERROR: Path not found: ${searchPath}`;
1572
+ }
1573
+ const regex = globToRegex(pattern);
1574
+ const results = [];
1575
+ let filesScanned = 0;
1576
+ function walk(dir, depth, relPrefix) {
1577
+ if (depth > GLOB_MAX_DEPTH)
1578
+ return;
1579
+ if (results.length >= GLOB_MAX_RESULTS)
1580
+ return;
1581
+ if (filesScanned >= GLOB_MAX_FILES_SCANNED)
1582
+ return;
1583
+ let entries;
1584
+ try {
1585
+ entries = readdirSync(dir);
1586
+ }
1587
+ catch {
1588
+ return;
1589
+ }
1590
+ for (const name of entries) {
1591
+ if (results.length >= GLOB_MAX_RESULTS)
1592
+ return;
1593
+ if (filesScanned >= GLOB_MAX_FILES_SCANNED)
1594
+ return;
1595
+ if (GLOB_SKIP_DIRS.has(name))
1596
+ continue;
1597
+ if (name.startsWith(".") && name !== ".")
1598
+ continue;
1599
+ const full = path.join(dir, name);
1600
+ const rel = relPrefix ? relPrefix + "/" + name : name;
1601
+ let stat;
1602
+ try {
1603
+ stat = statSync(full);
1604
+ }
1605
+ catch {
1606
+ continue;
1607
+ }
1608
+ if (stat.isDirectory()) {
1609
+ walk(full, depth + 1, rel);
1610
+ }
1611
+ else if (stat.isFile()) {
1612
+ filesScanned++;
1613
+ if (regex.test(rel)) {
1614
+ results.push({ path: full, mtime: stat.mtimeMs });
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ walk(searchPath, 0, "");
1620
+ if (results.length === 0) {
1621
+ return "No files found matching the pattern.";
1622
+ }
1623
+ // Sort by mtime descending (most recently modified first)
1624
+ results.sort((a, b) => b.mtime - a.mtime);
1625
+ const lines = results.map((r) => r.path);
1626
+ let output = lines.join("\n");
1627
+ if (results.length >= GLOB_MAX_RESULTS) {
1628
+ output += `\n... (truncated at ${GLOB_MAX_RESULTS} results)`;
1629
+ }
1630
+ return output;
1631
+ }
1632
+ /** Check if a filename matches a simple glob pattern (e.g. "*.ts", "*.{ts,tsx}") */
1633
+ function matchFileGlob(filename, globPattern) {
1634
+ const regex = globToRegex(globPattern);
1635
+ return regex.test(filename);
1636
+ }
1637
+ /** Check if file extension matches a type filter */
1638
+ function matchFileType(filename, typeFilter) {
1639
+ const ext = path.extname(filename).slice(1).toLowerCase();
1640
+ return ext === typeFilter.toLowerCase();
1641
+ }
1642
+ function toolGrep(pattern, searchPath, options) {
1643
+ if (!existsSync(searchPath)) {
1644
+ return `ERROR: Path not found: ${searchPath}`;
1645
+ }
1646
+ if (!pattern) {
1647
+ return "ERROR: pattern must be a non-empty string.";
1648
+ }
1649
+ if (pattern.length > SEARCH_MAX_PATTERN_LENGTH) {
1650
+ return (`ERROR: Regex pattern too long (${pattern.length} chars, ` +
1651
+ `limit ${SEARCH_MAX_PATTERN_LENGTH}).`);
1652
+ }
1653
+ // Catastrophic backtracking check
1654
+ if (/(^|[^\\])\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)[+*{]/.test(pattern)) {
1655
+ return "ERROR: Regex appears too complex/risky (nested quantified group).";
1656
+ }
1657
+ let regex;
1658
+ try {
1659
+ const flags = options.caseInsensitive ? "i" : "";
1660
+ regex = new RegExp(pattern, flags);
1661
+ }
1662
+ catch (e) {
1663
+ return `ERROR: Invalid regex: ${e instanceof Error ? e.message : String(e)}`;
1664
+ }
1665
+ const startedAt = Date.now();
1666
+ const stats = {
1667
+ filesScanned: 0,
1668
+ bytesScanned: 0,
1669
+ skippedLargeFiles: 0,
1670
+ skippedSensitiveFiles: 0,
1671
+ depthLimitHits: 0,
1672
+ maxFilesHit: false,
1673
+ maxBytesHit: false,
1674
+ timeoutHit: false,
1675
+ };
1676
+ // Results storage depends on output mode
1677
+ const fileMatches = [];
1678
+ let totalEntries = 0;
1679
+ function shouldStop() {
1680
+ if (options.headLimit > 0 && totalEntries >= options.headLimit)
1681
+ return true;
1682
+ if (stats.maxFilesHit || stats.maxBytesHit || stats.timeoutHit)
1683
+ return true;
1684
+ if (Date.now() - startedAt > SEARCH_MAX_DURATION_MS) {
1685
+ stats.timeoutHit = true;
1686
+ return true;
1687
+ }
1688
+ return false;
1689
+ }
1690
+ function shouldIncludeFile(filename) {
1691
+ if (options.glob && !matchFileGlob(filename, options.glob))
1692
+ return false;
1693
+ if (options.fileType && !matchFileType(filename, options.fileType))
1694
+ return false;
1695
+ return true;
1696
+ }
1697
+ function processFile(filePath) {
1698
+ let raw;
1699
+ try {
1700
+ raw = readFileSync(filePath);
1701
+ }
1702
+ catch {
1703
+ return;
1704
+ }
1705
+ // Skip binary files
1706
+ const header = raw.subarray(0, 8192);
1707
+ if (header.includes(0))
1708
+ return;
1709
+ const text = raw.toString("utf-8");
1710
+ const lines = text.split("\n");
1711
+ const matchingLines = [];
1712
+ for (let i = 0; i < lines.length; i++) {
1713
+ if (regex.global || regex.sticky)
1714
+ regex.lastIndex = 0;
1715
+ if (regex.test(lines[i])) {
1716
+ matchingLines.push({ line: i + 1, text: lines[i].trimEnd() });
1717
+ }
1718
+ }
1719
+ if (matchingLines.length > 0) {
1720
+ fileMatches.push({
1721
+ file: filePath,
1722
+ matches: matchingLines,
1723
+ count: matchingLines.length,
1724
+ });
1725
+ totalEntries++;
1726
+ }
1727
+ }
1728
+ function walkForGrep(dir, depth) {
1729
+ if (shouldStop())
1730
+ return;
1731
+ if (depth > SEARCH_MAX_DEPTH) {
1732
+ stats.depthLimitHits += 1;
1733
+ return;
1734
+ }
1735
+ let entries;
1736
+ try {
1737
+ entries = readdirSync(dir);
1738
+ }
1739
+ catch {
1740
+ return;
1741
+ }
1742
+ for (const name of entries) {
1743
+ if (shouldStop())
1744
+ return;
1745
+ if (name.startsWith(".") || name === "__pycache__" || name === "node_modules")
1746
+ continue;
1747
+ const full = path.join(dir, name);
1748
+ let stat;
1749
+ try {
1750
+ stat = statSync(full);
1751
+ }
1752
+ catch {
1753
+ continue;
1754
+ }
1755
+ if (stat.isDirectory()) {
1756
+ walkForGrep(full, depth + 1);
1757
+ }
1758
+ else if (stat.isFile()) {
1759
+ if (!shouldIncludeFile(name))
1760
+ continue;
1761
+ if (getSensitiveFileReadReason(full)) {
1762
+ stats.skippedSensitiveFiles += 1;
1763
+ continue;
1764
+ }
1765
+ if (stats.filesScanned >= SEARCH_MAX_FILES) {
1766
+ stats.maxFilesHit = true;
1767
+ return;
1768
+ }
1769
+ stats.filesScanned += 1;
1770
+ if (stat.size > SEARCH_MAX_FILE_SIZE) {
1771
+ stats.skippedLargeFiles += 1;
1772
+ continue;
1773
+ }
1774
+ if (stats.bytesScanned + stat.size > SEARCH_MAX_TOTAL_BYTES) {
1775
+ stats.maxBytesHit = true;
1776
+ return;
1777
+ }
1778
+ stats.bytesScanned += stat.size;
1779
+ processFile(full);
1780
+ }
1781
+ }
1782
+ }
1783
+ // Handle single file path
1784
+ const pathStat = statSync(searchPath);
1785
+ if (pathStat.isFile()) {
1786
+ if (shouldIncludeFile(path.basename(searchPath))) {
1787
+ processFile(searchPath);
1788
+ }
1789
+ }
1790
+ else {
1791
+ walkForGrep(searchPath, 0);
1792
+ }
1793
+ // Format output based on mode
1794
+ let output = "";
1795
+ const { outputMode } = options;
1796
+ if (fileMatches.length === 0) {
1797
+ output = "No matches found.";
1798
+ }
1799
+ else if (outputMode === "files_with_matches") {
1800
+ const lines = fileMatches.map((f) => f.file);
1801
+ output = lines.join("\n");
1802
+ }
1803
+ else if (outputMode === "count") {
1804
+ const lines = fileMatches.map((f) => `${f.file}:${f.count}`);
1805
+ output = lines.join("\n");
1806
+ }
1807
+ else {
1808
+ // content mode — show matching lines with optional context
1809
+ const parts = [];
1810
+ const beforeCtx = options.beforeContext;
1811
+ const afterCtx = options.afterContext;
1812
+ const showNumbers = options.showLineNumbers;
1813
+ for (const fm of fileMatches) {
1814
+ if (options.headLimit > 0 && parts.length >= options.headLimit)
1815
+ break;
1816
+ if (beforeCtx > 0 || afterCtx > 0) {
1817
+ // Need to re-read file for context lines
1818
+ let fileLines;
1819
+ try {
1820
+ fileLines = readFileSync(fm.file, "utf-8").split("\n");
1821
+ }
1822
+ catch {
1823
+ continue;
1824
+ }
1825
+ for (const m of fm.matches) {
1826
+ if (options.headLimit > 0 && parts.length >= options.headLimit)
1827
+ break;
1828
+ const startL = Math.max(0, m.line - 1 - beforeCtx);
1829
+ const endL = Math.min(fileLines.length, m.line + afterCtx);
1830
+ for (let li = startL; li < endL; li++) {
1831
+ const isMatch = li === m.line - 1;
1832
+ const prefix = isMatch ? ">" : " ";
1833
+ const lineText = fileLines[li].trimEnd();
1834
+ if (showNumbers) {
1835
+ parts.push(`${fm.file}:${li + 1}:${prefix} ${lineText}`);
1836
+ }
1837
+ else {
1838
+ parts.push(`${fm.file}:${prefix} ${lineText}`);
1839
+ }
1840
+ }
1841
+ parts.push("--");
1842
+ }
1843
+ }
1844
+ else {
1845
+ // No context — just matching lines
1846
+ for (const m of fm.matches) {
1847
+ if (options.headLimit > 0 && parts.length >= options.headLimit)
1848
+ break;
1849
+ if (showNumbers) {
1850
+ parts.push(`${fm.file}:${m.line}: ${m.text}`);
1851
+ }
1852
+ else {
1853
+ parts.push(`${fm.file}: ${m.text}`);
1854
+ }
1855
+ }
1856
+ }
1857
+ }
1858
+ output = parts.join("\n");
1859
+ }
1860
+ // Append notices
1861
+ const notices = [];
1862
+ if (stats.skippedLargeFiles > 0) {
1863
+ notices.push(`Skipped ${stats.skippedLargeFiles} large file(s) over ${Math.round(SEARCH_MAX_FILE_SIZE / 1024)} KB.`);
1864
+ }
1865
+ if (stats.skippedSensitiveFiles > 0) {
1866
+ notices.push(`Skipped ${stats.skippedSensitiveFiles} sensitive file(s).`);
1867
+ }
1868
+ if (stats.depthLimitHits > 0) {
1869
+ notices.push(`Depth limit reached in ${stats.depthLimitHits} director${stats.depthLimitHits === 1 ? "y" : "ies"} (max depth ${SEARCH_MAX_DEPTH}).`);
1870
+ }
1871
+ if (stats.maxFilesHit) {
1872
+ notices.push(`Stopped after scanning ${SEARCH_MAX_FILES} files.`);
1873
+ }
1874
+ if (stats.maxBytesHit) {
1875
+ notices.push(`Stopped after scanning ${Math.round(SEARCH_MAX_TOTAL_BYTES / 1024 / 1024)} MB.`);
1876
+ }
1877
+ if (stats.timeoutHit) {
1878
+ notices.push(`Stopped after ${SEARCH_MAX_DURATION_MS}ms time limit.`);
1879
+ }
1880
+ if (notices.length > 0) {
1881
+ output += "\n\n[Search notices]\n" + notices.map((n) => `- ${n}`).join("\n");
1882
+ }
1883
+ return output;
1884
+ }
1885
+ function createDispatch(ctx) {
1886
+ return {
1887
+ read_file: (args) => {
1888
+ try {
1889
+ const a = expectArgsObject("read_file", args);
1890
+ const requestedPath = requiredStringArg("read_file", a, "path", { nonEmpty: true });
1891
+ const startLine = optionalIntegerArg("read_file", a, "start_line");
1892
+ const endLine = optionalIntegerArg("read_file", a, "end_line");
1893
+ const filePath = scopedPath(requestedPath, "read", ctx, { mustExist: true, expectFile: true });
1894
+ return toolReadFile(filePath, startLine, endLine, ctx?.sessionArtifactsDir, ctx?.supportsMultimodal);
1895
+ }
1896
+ catch (e) {
1897
+ return formatToolError("read_file", e);
1898
+ }
1899
+ },
1900
+ list_dir: (args) => {
1901
+ try {
1902
+ const a = expectArgsObject("list_dir", args);
1903
+ const requestedPath = optionalStringArg("list_dir", a, "path", ".");
1904
+ const dirPath = scopedPath(requestedPath, "list", ctx, { mustExist: true, expectDirectory: true });
1905
+ return toolListDir(dirPath);
1906
+ }
1907
+ catch (e) {
1908
+ return formatToolError("list_dir", e);
1909
+ }
1910
+ },
1911
+ edit_file: (args) => {
1912
+ try {
1913
+ const a = expectArgsObject("edit_file", args);
1914
+ const requestedPath = requiredStringArg("edit_file", a, "path", { nonEmpty: true });
1915
+ const oldStr = requiredStringArg("edit_file", a, "old_str", { nonEmpty: true });
1916
+ const newStr = requiredStringArg("edit_file", a, "new_str");
1917
+ const expectedMtimeMs = optionalIntegerArg("edit_file", a, "expected_mtime_ms");
1918
+ const filePath = scopedPath(requestedPath, "write", ctx, { mustExist: true, expectFile: true });
1919
+ return toolEditFile(filePath, oldStr, newStr, expectedMtimeMs);
1920
+ }
1921
+ catch (e) {
1922
+ return formatToolError("edit_file", e);
1923
+ }
1924
+ },
1925
+ write_file: (args) => {
1926
+ try {
1927
+ const a = expectArgsObject("write_file", args);
1928
+ const requestedPath = requiredStringArg("write_file", a, "path", { nonEmpty: true });
1929
+ const content = requiredStringArg("write_file", a, "content");
1930
+ const expectedMtimeMs = optionalIntegerArg("write_file", a, "expected_mtime_ms");
1931
+ const filePath = scopedPath(requestedPath, "write", ctx, { allowCreate: true, expectFile: true });
1932
+ return toolWriteFile(filePath, content, expectedMtimeMs);
1933
+ }
1934
+ catch (e) {
1935
+ return formatToolError("write_file", e);
1936
+ }
1937
+ },
1938
+ apply_patch: async (args) => {
1939
+ try {
1940
+ const a = expectArgsObject("apply_patch", args);
1941
+ const patch = requiredStringArg("apply_patch", a, "patch", { nonEmpty: true, maxLen: 200_000 });
1942
+ return await toolApplyPatch(patch, ctx);
1943
+ }
1944
+ catch (e) {
1945
+ if (e instanceof ToolArgValidationError) {
1946
+ return formatToolError("apply_patch", e);
1947
+ }
1948
+ return `ERROR: apply_patch verification failed: ${e instanceof Error ? e.message : String(e)}`;
1949
+ }
1950
+ },
1951
+ bash: (args) => {
1952
+ try {
1953
+ const a = expectArgsObject("bash", args);
1954
+ const command = requiredStringArg("bash", a, "command", { nonEmpty: true, maxLen: 20_000 });
1955
+ const timeout = optionalIntegerArg("bash", a, "timeout");
1956
+ const cwdArg = optionalStringArg("bash", a, "cwd", "");
1957
+ let cwd = "";
1958
+ if (cwdArg.trim()) {
1959
+ cwd = scopedPath(cwdArg, "list", ctx, { mustExist: true, expectDirectory: true });
1960
+ }
1961
+ return toolBash(command, timeout ?? BASH_DEFAULT_TIMEOUT, cwd);
1962
+ }
1963
+ catch (e) {
1964
+ return formatToolError("bash", e);
1965
+ }
1966
+ },
1967
+ diff: (args) => {
1968
+ try {
1969
+ const a = expectArgsObject("diff", args);
1970
+ const fileAArg = requiredStringArg("diff", a, "file_a", { nonEmpty: true });
1971
+ const rawFileB = optionalStringArg("diff", a, "file_b", "");
1972
+ const hasContentB = Object.prototype.hasOwnProperty.call(a, "content_b");
1973
+ const contentB = optionalStringArg("diff", a, "content_b", "");
1974
+ const fileA = scopedPath(fileAArg, "diff", ctx, { mustExist: true, expectFile: true });
1975
+ let fileB = "";
1976
+ if (!hasContentB && rawFileB) {
1977
+ fileB = scopedPath(rawFileB, "diff", ctx, { mustExist: true, expectFile: true });
1978
+ }
1979
+ else {
1980
+ fileB = rawFileB;
1981
+ }
1982
+ return toolDiff(fileA, fileB, contentB, hasContentB);
1983
+ }
1984
+ catch (e) {
1985
+ return formatToolError("diff", e);
1986
+ }
1987
+ },
1988
+ test: (args) => {
1989
+ try {
1990
+ const a = expectArgsObject("test", args);
1991
+ const command = optionalStringArg("test", a, "command", "python -m pytest");
1992
+ const timeout = optionalIntegerArg("test", a, "timeout");
1993
+ return toolTest(command, timeout ?? 60);
1994
+ }
1995
+ catch (e) {
1996
+ return formatToolError("test", e);
1997
+ }
1998
+ },
1999
+ glob: (args) => {
2000
+ try {
2001
+ const a = expectArgsObject("glob", args);
2002
+ const pattern = requiredStringArg("glob", a, "pattern", { nonEmpty: true });
2003
+ const requestedPath = optionalStringArg("glob", a, "path", ".");
2004
+ const globPath = scopedPath(requestedPath, "search", ctx, { mustExist: true, expectDirectory: true });
2005
+ return toolGlob(pattern, globPath);
2006
+ }
2007
+ catch (e) {
2008
+ return formatToolError("glob", e);
2009
+ }
2010
+ },
2011
+ grep: (args) => {
2012
+ try {
2013
+ const a = expectArgsObject("grep", args);
2014
+ const pattern = requiredStringArg("grep", a, "pattern", { nonEmpty: true, maxLen: SEARCH_MAX_PATTERN_LENGTH });
2015
+ const requestedPath = optionalStringArg("grep", a, "path", ".");
2016
+ const searchPath = scopedPath(requestedPath, "search", ctx, { mustExist: true });
2017
+ const globFilter = optionalStringArg("grep", a, "glob", "");
2018
+ const fileType = optionalStringArg("grep", a, "type", "");
2019
+ const outputMode = optionalStringArg("grep", a, "output_mode", "files_with_matches");
2020
+ const afterCtx = optionalIntegerArg("grep", a, "-A") ?? 0;
2021
+ const beforeCtx = optionalIntegerArg("grep", a, "-B") ?? 0;
2022
+ const contextCtx = optionalIntegerArg("grep", a, "-C") ?? 0;
2023
+ const caseInsensitive = a["-i"] === true;
2024
+ const showLineNumbers = a["-n"] !== false; // default true
2025
+ const headLimit = optionalIntegerArg("grep", a, "head_limit") ?? 0;
2026
+ return toolGrep(pattern, searchPath, {
2027
+ glob: globFilter || undefined,
2028
+ fileType: fileType || undefined,
2029
+ outputMode,
2030
+ afterContext: contextCtx > 0 ? contextCtx : afterCtx,
2031
+ beforeContext: contextCtx > 0 ? contextCtx : beforeCtx,
2032
+ caseInsensitive,
2033
+ showLineNumbers,
2034
+ headLimit,
2035
+ });
2036
+ }
2037
+ catch (e) {
2038
+ return formatToolError("grep", e);
2039
+ }
2040
+ },
2041
+ web_fetch: async (args) => {
2042
+ try {
2043
+ const a = expectArgsObject("web_fetch", args);
2044
+ const url = requiredStringArg("web_fetch", a, "url", { nonEmpty: true });
2045
+ const prompt = optionalStringArg("web_fetch", a, "prompt", "");
2046
+ return toolWebFetch(url, prompt || undefined);
2047
+ }
2048
+ catch (e) {
2049
+ return formatToolError("web_fetch", e);
2050
+ }
2051
+ },
2052
+ $web_search: (args) => toolBuiltinWebSearchPassthrough(args),
2053
+ };
2054
+ }
2055
+ /**
2056
+ * Execute a tool by name and return a `ToolResult`.
2057
+ *
2058
+ * Tool functions may return either a plain `string` (wrapped automatically)
2059
+ * or a `ToolResult` with optional action hints, tags, and metadata.
2060
+ */
2061
+ export async function executeTool(name, args, ctx) {
2062
+ const fn = createDispatch(ctx)[name];
2063
+ if (!fn) {
2064
+ return new ToolResult({ content: `ERROR: Unknown tool '${name}'` });
2065
+ }
2066
+ try {
2067
+ const raw = await fn(args);
2068
+ if (raw instanceof ToolResult) {
2069
+ return raw;
2070
+ }
2071
+ return new ToolResult({ content: raw });
2072
+ }
2073
+ catch (e) {
2074
+ return new ToolResult({
2075
+ content: `ERROR executing ${name}: ${e instanceof Error ? e.message : String(e)}`,
2076
+ });
2077
+ }
2078
+ }
2079
+ //# sourceMappingURL=basic.js.map