fixo-cli 1.0.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 (303) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +530 -0
  3. package/dist/agent/agent-client.d.ts +108 -0
  4. package/dist/agent/agent-client.d.ts.map +1 -0
  5. package/dist/agent/agent-client.js +1247 -0
  6. package/dist/agent/agent-client.js.map +1 -0
  7. package/dist/agent/agent-pool.d.ts +20 -0
  8. package/dist/agent/agent-pool.d.ts.map +1 -0
  9. package/dist/agent/agent-pool.js +217 -0
  10. package/dist/agent/agent-pool.js.map +1 -0
  11. package/dist/agent/background-awareness.d.ts +55 -0
  12. package/dist/agent/background-awareness.d.ts.map +1 -0
  13. package/dist/agent/background-awareness.js +104 -0
  14. package/dist/agent/background-awareness.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts +33 -0
  16. package/dist/agent/command-parser.d.ts.map +1 -0
  17. package/dist/agent/command-parser.js +120 -0
  18. package/dist/agent/command-parser.js.map +1 -0
  19. package/dist/agent/context-budget.d.ts +91 -0
  20. package/dist/agent/context-budget.d.ts.map +1 -0
  21. package/dist/agent/context-budget.js +219 -0
  22. package/dist/agent/context-budget.js.map +1 -0
  23. package/dist/agent/conversation.d.ts +190 -0
  24. package/dist/agent/conversation.d.ts.map +1 -0
  25. package/dist/agent/conversation.js +547 -0
  26. package/dist/agent/conversation.js.map +1 -0
  27. package/dist/agent/hooks.d.ts +72 -0
  28. package/dist/agent/hooks.d.ts.map +1 -0
  29. package/dist/agent/hooks.js +214 -0
  30. package/dist/agent/hooks.js.map +1 -0
  31. package/dist/agent/mcp-bridge.d.ts +13 -0
  32. package/dist/agent/mcp-bridge.d.ts.map +1 -0
  33. package/dist/agent/mcp-bridge.js +86 -0
  34. package/dist/agent/mcp-bridge.js.map +1 -0
  35. package/dist/agent/mcp-client.d.ts +24 -0
  36. package/dist/agent/mcp-client.d.ts.map +1 -0
  37. package/dist/agent/mcp-client.js +146 -0
  38. package/dist/agent/mcp-client.js.map +1 -0
  39. package/dist/agent/mcp-manager.d.ts +13 -0
  40. package/dist/agent/mcp-manager.d.ts.map +1 -0
  41. package/dist/agent/mcp-manager.js +84 -0
  42. package/dist/agent/mcp-manager.js.map +1 -0
  43. package/dist/agent/mcp-registry.d.ts +45 -0
  44. package/dist/agent/mcp-registry.d.ts.map +1 -0
  45. package/dist/agent/mcp-registry.js +98 -0
  46. package/dist/agent/mcp-registry.js.map +1 -0
  47. package/dist/agent/orchestrator.d.ts +14 -0
  48. package/dist/agent/orchestrator.d.ts.map +1 -0
  49. package/dist/agent/orchestrator.js +118 -0
  50. package/dist/agent/orchestrator.js.map +1 -0
  51. package/dist/agent/parser-adapter.d.ts +120 -0
  52. package/dist/agent/parser-adapter.d.ts.map +1 -0
  53. package/dist/agent/parser-adapter.js +265 -0
  54. package/dist/agent/parser-adapter.js.map +1 -0
  55. package/dist/agent/parsers/imports.d.ts +11 -0
  56. package/dist/agent/parsers/imports.d.ts.map +1 -0
  57. package/dist/agent/parsers/imports.js +94 -0
  58. package/dist/agent/parsers/imports.js.map +1 -0
  59. package/dist/agent/parsers/shell.d.ts +23 -0
  60. package/dist/agent/parsers/shell.d.ts.map +1 -0
  61. package/dist/agent/parsers/shell.js +200 -0
  62. package/dist/agent/parsers/shell.js.map +1 -0
  63. package/dist/agent/parsers/symbols.d.ts +17 -0
  64. package/dist/agent/parsers/symbols.d.ts.map +1 -0
  65. package/dist/agent/parsers/symbols.js +103 -0
  66. package/dist/agent/parsers/symbols.js.map +1 -0
  67. package/dist/agent/permissions.d.ts +65 -0
  68. package/dist/agent/permissions.d.ts.map +1 -0
  69. package/dist/agent/permissions.js +219 -0
  70. package/dist/agent/permissions.js.map +1 -0
  71. package/dist/agent/predictive-gate.d.ts +69 -0
  72. package/dist/agent/predictive-gate.d.ts.map +1 -0
  73. package/dist/agent/predictive-gate.js +128 -0
  74. package/dist/agent/predictive-gate.js.map +1 -0
  75. package/dist/agent/provider-cooldown.d.ts +144 -0
  76. package/dist/agent/provider-cooldown.d.ts.map +1 -0
  77. package/dist/agent/provider-cooldown.js +300 -0
  78. package/dist/agent/provider-cooldown.js.map +1 -0
  79. package/dist/agent/providers-manager.d.ts +109 -0
  80. package/dist/agent/providers-manager.d.ts.map +1 -0
  81. package/dist/agent/providers-manager.js +464 -0
  82. package/dist/agent/providers-manager.js.map +1 -0
  83. package/dist/agent/repo-map.d.ts +6 -0
  84. package/dist/agent/repo-map.d.ts.map +1 -0
  85. package/dist/agent/repo-map.js +221 -0
  86. package/dist/agent/repo-map.js.map +1 -0
  87. package/dist/agent/retry.d.ts +103 -0
  88. package/dist/agent/retry.d.ts.map +1 -0
  89. package/dist/agent/retry.js +276 -0
  90. package/dist/agent/retry.js.map +1 -0
  91. package/dist/agent/search/index.d.ts +61 -0
  92. package/dist/agent/search/index.d.ts.map +1 -0
  93. package/dist/agent/search/index.js +314 -0
  94. package/dist/agent/search/index.js.map +1 -0
  95. package/dist/agent/single-agent.d.ts +76 -0
  96. package/dist/agent/single-agent.d.ts.map +1 -0
  97. package/dist/agent/single-agent.js +697 -0
  98. package/dist/agent/single-agent.js.map +1 -0
  99. package/dist/agent/skills.d.ts +22 -0
  100. package/dist/agent/skills.d.ts.map +1 -0
  101. package/dist/agent/skills.js +139 -0
  102. package/dist/agent/skills.js.map +1 -0
  103. package/dist/agent/stream-glue.d.ts +85 -0
  104. package/dist/agent/stream-glue.d.ts.map +1 -0
  105. package/dist/agent/stream-glue.js +120 -0
  106. package/dist/agent/stream-glue.js.map +1 -0
  107. package/dist/agent/subagent.d.ts +72 -0
  108. package/dist/agent/subagent.d.ts.map +1 -0
  109. package/dist/agent/subagent.js +193 -0
  110. package/dist/agent/subagent.js.map +1 -0
  111. package/dist/agent/telemetry.d.ts +192 -0
  112. package/dist/agent/telemetry.d.ts.map +1 -0
  113. package/dist/agent/telemetry.js +400 -0
  114. package/dist/agent/telemetry.js.map +1 -0
  115. package/dist/agent/tokenizer.d.ts +42 -0
  116. package/dist/agent/tokenizer.d.ts.map +1 -0
  117. package/dist/agent/tokenizer.js +107 -0
  118. package/dist/agent/tokenizer.js.map +1 -0
  119. package/dist/agent/tool-executor.d.ts +289 -0
  120. package/dist/agent/tool-executor.d.ts.map +1 -0
  121. package/dist/agent/tool-executor.js +2519 -0
  122. package/dist/agent/tool-executor.js.map +1 -0
  123. package/dist/agent/web-impl.d.ts +2 -0
  124. package/dist/agent/web-impl.d.ts.map +1 -0
  125. package/dist/agent/web-impl.js +34 -0
  126. package/dist/agent/web-impl.js.map +1 -0
  127. package/dist/agent/web.d.ts +8 -0
  128. package/dist/agent/web.d.ts.map +1 -0
  129. package/dist/agent/web.js +8 -0
  130. package/dist/agent/web.js.map +1 -0
  131. package/dist/agent/worker-agent.d.ts +27 -0
  132. package/dist/agent/worker-agent.d.ts.map +1 -0
  133. package/dist/agent/worker-agent.js +503 -0
  134. package/dist/agent/worker-agent.js.map +1 -0
  135. package/dist/config.d.ts +162 -0
  136. package/dist/config.d.ts.map +1 -0
  137. package/dist/config.js +138 -0
  138. package/dist/config.js.map +1 -0
  139. package/dist/context/fixo-md-watcher.d.ts +42 -0
  140. package/dist/context/fixo-md-watcher.d.ts.map +1 -0
  141. package/dist/context/fixo-md-watcher.js +126 -0
  142. package/dist/context/fixo-md-watcher.js.map +1 -0
  143. package/dist/context/fixo-md.d.ts +50 -0
  144. package/dist/context/fixo-md.d.ts.map +1 -0
  145. package/dist/context/fixo-md.js +118 -0
  146. package/dist/context/fixo-md.js.map +1 -0
  147. package/dist/context/todo.d.ts +65 -0
  148. package/dist/context/todo.d.ts.map +1 -0
  149. package/dist/context/todo.js +194 -0
  150. package/dist/context/todo.js.map +1 -0
  151. package/dist/git/git-manager.d.ts +33 -0
  152. package/dist/git/git-manager.d.ts.map +1 -0
  153. package/dist/git/git-manager.js +293 -0
  154. package/dist/git/git-manager.js.map +1 -0
  155. package/dist/git/git-ops.d.ts +10 -0
  156. package/dist/git/git-ops.d.ts.map +1 -0
  157. package/dist/git/git-ops.js +131 -0
  158. package/dist/git/git-ops.js.map +1 -0
  159. package/dist/index.d.ts +3 -0
  160. package/dist/index.d.ts.map +1 -0
  161. package/dist/index.js +352 -0
  162. package/dist/index.js.map +1 -0
  163. package/dist/indexer.d.ts +30 -0
  164. package/dist/indexer.d.ts.map +1 -0
  165. package/dist/indexer.js +273 -0
  166. package/dist/indexer.js.map +1 -0
  167. package/dist/lsp/lsp-client.d.ts +24 -0
  168. package/dist/lsp/lsp-client.d.ts.map +1 -0
  169. package/dist/lsp/lsp-client.js +205 -0
  170. package/dist/lsp/lsp-client.js.map +1 -0
  171. package/dist/lsp/lsp-manager.d.ts +17 -0
  172. package/dist/lsp/lsp-manager.d.ts.map +1 -0
  173. package/dist/lsp/lsp-manager.js +154 -0
  174. package/dist/lsp/lsp-manager.js.map +1 -0
  175. package/dist/lsp/lsp-pre-save.d.ts +137 -0
  176. package/dist/lsp/lsp-pre-save.d.ts.map +1 -0
  177. package/dist/lsp/lsp-pre-save.js +245 -0
  178. package/dist/lsp/lsp-pre-save.js.map +1 -0
  179. package/dist/lsp/syntax-fallback.d.ts +83 -0
  180. package/dist/lsp/syntax-fallback.d.ts.map +1 -0
  181. package/dist/lsp/syntax-fallback.js +275 -0
  182. package/dist/lsp/syntax-fallback.js.map +1 -0
  183. package/dist/model-outcomes.d.ts +12 -0
  184. package/dist/model-outcomes.d.ts.map +1 -0
  185. package/dist/model-outcomes.js +46 -0
  186. package/dist/model-outcomes.js.map +1 -0
  187. package/dist/planner.d.ts +32 -0
  188. package/dist/planner.d.ts.map +1 -0
  189. package/dist/planner.js +163 -0
  190. package/dist/planner.js.map +1 -0
  191. package/dist/project-memory.d.ts +29 -0
  192. package/dist/project-memory.d.ts.map +1 -0
  193. package/dist/project-memory.js +349 -0
  194. package/dist/project-memory.js.map +1 -0
  195. package/dist/review.d.ts +2 -0
  196. package/dist/review.d.ts.map +1 -0
  197. package/dist/review.js +61 -0
  198. package/dist/review.js.map +1 -0
  199. package/dist/runtime/background-jobs.d.ts +97 -0
  200. package/dist/runtime/background-jobs.d.ts.map +1 -0
  201. package/dist/runtime/background-jobs.js +331 -0
  202. package/dist/runtime/background-jobs.js.map +1 -0
  203. package/dist/runtime/credential-vault.d.ts +124 -0
  204. package/dist/runtime/credential-vault.d.ts.map +1 -0
  205. package/dist/runtime/credential-vault.js +184 -0
  206. package/dist/runtime/credential-vault.js.map +1 -0
  207. package/dist/runtime/loop-trap.d.ts +197 -0
  208. package/dist/runtime/loop-trap.d.ts.map +1 -0
  209. package/dist/runtime/loop-trap.js +420 -0
  210. package/dist/runtime/loop-trap.js.map +1 -0
  211. package/dist/runtime/policy.d.ts +15 -0
  212. package/dist/runtime/policy.d.ts.map +1 -0
  213. package/dist/runtime/policy.js +60 -0
  214. package/dist/runtime/policy.js.map +1 -0
  215. package/dist/runtime/redaction.d.ts +66 -0
  216. package/dist/runtime/redaction.d.ts.map +1 -0
  217. package/dist/runtime/redaction.js +155 -0
  218. package/dist/runtime/redaction.js.map +1 -0
  219. package/dist/runtime/session-snapshots.d.ts +76 -0
  220. package/dist/runtime/session-snapshots.d.ts.map +1 -0
  221. package/dist/runtime/session-snapshots.js +166 -0
  222. package/dist/runtime/session-snapshots.js.map +1 -0
  223. package/dist/runtime/staging.d.ts +205 -0
  224. package/dist/runtime/staging.d.ts.map +1 -0
  225. package/dist/runtime/staging.js +526 -0
  226. package/dist/runtime/staging.js.map +1 -0
  227. package/dist/runtime/task-session.d.ts +95 -0
  228. package/dist/runtime/task-session.d.ts.map +1 -0
  229. package/dist/runtime/task-session.js +263 -0
  230. package/dist/runtime/task-session.js.map +1 -0
  231. package/dist/runtime/worktree.d.ts +55 -0
  232. package/dist/runtime/worktree.d.ts.map +1 -0
  233. package/dist/runtime/worktree.js +175 -0
  234. package/dist/runtime/worktree.js.map +1 -0
  235. package/dist/setup-wizard.d.ts +8 -0
  236. package/dist/setup-wizard.d.ts.map +1 -0
  237. package/dist/setup-wizard.js +73 -0
  238. package/dist/setup-wizard.js.map +1 -0
  239. package/dist/shared/content.d.ts +43 -0
  240. package/dist/shared/content.d.ts.map +1 -0
  241. package/dist/shared/content.js +61 -0
  242. package/dist/shared/content.js.map +1 -0
  243. package/dist/shared/types.d.ts +217 -0
  244. package/dist/shared/types.d.ts.map +1 -0
  245. package/dist/shared/types.js +3 -0
  246. package/dist/shared/types.js.map +1 -0
  247. package/dist/test-runner.d.ts +5 -0
  248. package/dist/test-runner.d.ts.map +1 -0
  249. package/dist/test-runner.js +42 -0
  250. package/dist/test-runner.js.map +1 -0
  251. package/dist/types.d.ts +85 -0
  252. package/dist/types.d.ts.map +1 -0
  253. package/dist/types.js +2 -0
  254. package/dist/types.js.map +1 -0
  255. package/dist/ui/ascii.d.ts +23 -0
  256. package/dist/ui/ascii.d.ts.map +1 -0
  257. package/dist/ui/ascii.js +45 -0
  258. package/dist/ui/ascii.js.map +1 -0
  259. package/dist/ui/colors.d.ts +111 -0
  260. package/dist/ui/colors.d.ts.map +1 -0
  261. package/dist/ui/colors.js +166 -0
  262. package/dist/ui/colors.js.map +1 -0
  263. package/dist/ui/image-attach.d.ts +27 -0
  264. package/dist/ui/image-attach.d.ts.map +1 -0
  265. package/dist/ui/image-attach.js +100 -0
  266. package/dist/ui/image-attach.js.map +1 -0
  267. package/dist/ui/index.d.ts +18 -0
  268. package/dist/ui/index.d.ts.map +1 -0
  269. package/dist/ui/index.js +18 -0
  270. package/dist/ui/index.js.map +1 -0
  271. package/dist/ui/markdown-stream.d.ts +91 -0
  272. package/dist/ui/markdown-stream.d.ts.map +1 -0
  273. package/dist/ui/markdown-stream.js +524 -0
  274. package/dist/ui/markdown-stream.js.map +1 -0
  275. package/dist/ui/plan-renderer.d.ts +36 -0
  276. package/dist/ui/plan-renderer.d.ts.map +1 -0
  277. package/dist/ui/plan-renderer.js +79 -0
  278. package/dist/ui/plan-renderer.js.map +1 -0
  279. package/dist/ui/prompt.d.ts +11 -0
  280. package/dist/ui/prompt.d.ts.map +1 -0
  281. package/dist/ui/prompt.js +1960 -0
  282. package/dist/ui/prompt.js.map +1 -0
  283. package/dist/ui/render-primitives.d.ts +117 -0
  284. package/dist/ui/render-primitives.d.ts.map +1 -0
  285. package/dist/ui/render-primitives.js +322 -0
  286. package/dist/ui/render-primitives.js.map +1 -0
  287. package/dist/ui/render.d.ts +133 -0
  288. package/dist/ui/render.d.ts.map +1 -0
  289. package/dist/ui/render.js +547 -0
  290. package/dist/ui/render.js.map +1 -0
  291. package/dist/ui/session-header.d.ts +30 -0
  292. package/dist/ui/session-header.d.ts.map +1 -0
  293. package/dist/ui/session-header.js +74 -0
  294. package/dist/ui/session-header.js.map +1 -0
  295. package/dist/workspace-guard.d.ts +68 -0
  296. package/dist/workspace-guard.d.ts.map +1 -0
  297. package/dist/workspace-guard.js +168 -0
  298. package/dist/workspace-guard.js.map +1 -0
  299. package/dist/workspace-lock.d.ts +27 -0
  300. package/dist/workspace-lock.d.ts.map +1 -0
  301. package/dist/workspace-lock.js +95 -0
  302. package/dist/workspace-lock.js.map +1 -0
  303. package/package.json +63 -0
@@ -0,0 +1,2519 @@
1
+ /**
2
+ * Tool definitions and executor for the single-agent tool-calling loop.
3
+ * Provides: read_file, write_file, run_command, search_code, list_dir
4
+ */
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { spawnSync } from 'child_process';
8
+ import { colors } from '../ui/colors.js';
9
+ import { renderToolCall } from '../ui/render-primitives.js';
10
+ import { WorkspaceGuard } from '../workspace-guard.js';
11
+ import { classifyCommand } from '../runtime/policy.js';
12
+ import { checkPermission } from './permissions.js';
13
+ import { redactedEnv, redactSecrets } from '../runtime/redaction.js';
14
+ import { McpManager } from './mcp-manager.js';
15
+ import { estimateReadCost, shouldDeferRead, formatPredictiveGateDirective, DEFAULT_PREDICTIVE_BUDGET_PCT } from './predictive-gate.js';
16
+ import { createBranch, commitChanges, pushBranch, createPullRequest } from '../git/git-ops.js';
17
+ import { pathToFileURL } from 'url';
18
+ import * as p from '@clack/prompts';
19
+ import { loadConfig, saveConfig } from '../config.js';
20
+ import { AtomicStagingManager } from '../runtime/staging.js';
21
+ import { LspPreSaveGate, makeLspProvider } from '../lsp/lsp-pre-save.js';
22
+ import { syntaxHealthCheck, formatSyntaxVerdict } from '../lsp/syntax-fallback.js';
23
+ import { PlatformPathLockedError } from '../workspace-guard.js';
24
+ import { McpBridgeManager } from './mcp-bridge.js';
25
+ import { LspManager } from '../lsp/lsp-manager.js';
26
+ import { webFetch, webSearch } from './web.js';
27
+ import { loadTodoList, saveTodoList, addItem, setItemStatus, removeItem, clearDoneItems, renderTodoList, summariseTodoList, } from '../context/todo.js';
28
+ import { recordTelemetry, telemetry } from './telemetry.js';
29
+ import { applyModifiedArgs, fireHooks } from './hooks.js';
30
+ import { BackgroundJobRegistry } from '../runtime/background-jobs.js';
31
+ import { ParserFactory, languageIdFromExtension, } from './parser-adapter.js';
32
+ export const mcpManager = new McpManager();
33
+ export const mcpBridgeManager = new McpBridgeManager();
34
+ let lspManagerInstance = null;
35
+ export function getLspManager(workspaceRoot) {
36
+ if (!lspManagerInstance) {
37
+ lspManagerInstance = new LspManager(workspaceRoot);
38
+ }
39
+ return lspManagerInstance;
40
+ }
41
+ export async function stopLspManager() {
42
+ if (lspManagerInstance) {
43
+ await lspManagerInstance.stopAll();
44
+ lspManagerInstance = null;
45
+ }
46
+ }
47
+ export const loadedPlugins = [];
48
+ export async function initializePlugins(cwd, projectConfig) {
49
+ if (!projectConfig || !projectConfig.plugins || !Array.isArray(projectConfig.plugins)) {
50
+ return;
51
+ }
52
+ const guard = new WorkspaceGuard(cwd);
53
+ const globalConfig = loadConfig();
54
+ if (!globalConfig.approvedPlugins) {
55
+ globalConfig.approvedPlugins = [];
56
+ }
57
+ const trusted = projectConfig.trustedPlugins || [];
58
+ for (const pluginPath of projectConfig.plugins) {
59
+ try {
60
+ const resolvedPath = guard.resolve(pluginPath);
61
+ const isTrusted = trusted.includes(pluginPath) || trusted.includes(resolvedPath);
62
+ if (!isTrusted) {
63
+ console.error(`\n${colors.red}[Plugin Loader] Error: Plugin "${pluginPath}" is listed in "plugins" but is not in the "trustedPlugins" allowlist inside .freellmapi.yml. Skipping.${colors.reset}`);
64
+ continue;
65
+ }
66
+ const fileUrl = pathToFileURL(resolvedPath).toString();
67
+ const mod = await import(fileUrl);
68
+ const tools = (mod.tools || []);
69
+ const execute = mod.execute;
70
+ if (typeof execute !== 'function') {
71
+ console.error(`\n${colors.red}[Plugin Loader] Error: Plugin "${pluginPath}" does not export an "execute" function. Skipping.${colors.reset}`);
72
+ continue;
73
+ }
74
+ const approvedKey = `${resolvedPath}`;
75
+ const isApproved = globalConfig.approvedPlugins.includes(approvedKey);
76
+ if (!isApproved) {
77
+ console.log(`\n${colors.yellow}╔════════════════════════════════════════════════════════════════╗`);
78
+ console.log(`║ PLUGIN SECURITY VERIFICATION ║`);
79
+ console.log(`╚════════════════════════════════════════════════════════════════╝`);
80
+ console.log(`A new plugin is requesting to be loaded for this workspace:`);
81
+ console.log(`- Path: ${colors.cyan}${pluginPath}${colors.reset}`);
82
+ console.log(`- Resolved: ${colors.cyan}${resolvedPath}${colors.reset}`);
83
+ console.log(`- Registers tools: ${colors.green}${tools.map(t => t.function.name).join(', ') || '(none)'}${colors.reset}`);
84
+ console.log(`\n${colors.yellow}WARNING: Plugins run with full access to the user shell and can make network calls or exfiltrate credentials.${colors.reset}`);
85
+ const confirmed = await p.confirm({
86
+ message: `Do you trust and want to load this plugin?`,
87
+ initialValue: false,
88
+ });
89
+ if (p.isCancel(confirmed) || !confirmed) {
90
+ console.log(`[Plugin Loader] Load cancelled for "${pluginPath}". Skipping.`);
91
+ continue;
92
+ }
93
+ globalConfig.approvedPlugins.push(approvedKey);
94
+ saveConfig(globalConfig);
95
+ console.log(`${colors.green}✓ Plugin approved and saved to ~/.fixocli/config.json${colors.reset}`);
96
+ }
97
+ loadedPlugins.push({
98
+ path: pluginPath,
99
+ tools,
100
+ execute,
101
+ });
102
+ }
103
+ catch (err) {
104
+ console.error(`\n${colors.red}[Plugin Loader] Failed to load plugin "${pluginPath}": ${err instanceof Error ? err.message : String(err)}${colors.reset}`);
105
+ }
106
+ }
107
+ }
108
+ /* ──────────────────────── Tool Definitions ──────────────────────── */
109
+ /**
110
+ * Names of all tools that perform a write / mutation. Used by
111
+ * {@link getActiveTools} to enforce role-based tool masking
112
+ * (Pillar 5 / Protection 2 — Strict Runtime Role Isolation).
113
+ * Kept as a Set for O(1) membership checks; never read in
114
+ * production paths.
115
+ */
116
+ export const MUTATION_TOOL_NAMES = new Set([
117
+ 'write_file',
118
+ 'apply_patch',
119
+ 'replace_range',
120
+ 'insert_after',
121
+ 'rename_file',
122
+ 'delete_file',
123
+ 'create_branch',
124
+ 'commit_changes',
125
+ 'push_branch',
126
+ 'create_pull_request',
127
+ 'run_command',
128
+ 'str_replace',
129
+ 'todo_write',
130
+ 'run_command_async',
131
+ ]);
132
+ /**
133
+ * Build the active tool list for a given execution role. The
134
+ * mode argument is the role the agent is operating in:
135
+ *
136
+ * - `BUILD` — the default; all tools are available.
137
+ * - `EXPLORE` — only read + LSP navigation tools.
138
+ * - `SCOUT` — only web fetch / search.
139
+ * - `PLAN` — read + web + LSP, but no mutations.
140
+ * - `READ_ONLY` (Pillar 5) — no mutation tools at all.
141
+ * This is the role forced on during vulnerability audits,
142
+ * reviews, and explanations, so the LLM has zero
143
+ * visibility of code-writing tools and cannot accidentally
144
+ * mutate the workspace.
145
+ */
146
+ export function getActiveTools(mode) {
147
+ const pluginTools = loadedPlugins.flatMap(p => p.tools);
148
+ let tools = [...TOOL_DEFINITIONS, ...mcpManager.getTools(), ...mcpBridgeManager.getTools(), ...pluginTools];
149
+ if (mode === 'EXPLORE') {
150
+ const allowed = ['read_file', 'list_dir', 'search_code', 'lsp_goto_definition', 'lsp_find_references', 'lsp_hover'];
151
+ tools = tools.filter(t => allowed.includes(t.function.name));
152
+ }
153
+ else if (mode === 'SCOUT') {
154
+ const allowed = ['web_fetch', 'web_search'];
155
+ tools = tools.filter(t => allowed.includes(t.function.name));
156
+ }
157
+ else if (mode === 'PLAN') {
158
+ const readOnly = ['read_file', 'list_dir', 'search_code', 'lsp_goto_definition', 'lsp_find_references', 'lsp_hover', 'web_fetch', 'web_search'];
159
+ tools = tools.filter(t => readOnly.includes(t.function.name));
160
+ }
161
+ else if (mode === 'READ_ONLY') {
162
+ // Pillar 5 — strip every mutation tool. The agent sees
163
+ // only read + search + LSP navigation.
164
+ tools = tools.filter(t => !MUTATION_TOOL_NAMES.has(t.function.name));
165
+ }
166
+ return tools;
167
+ }
168
+ /**
169
+ * Classify a task into an execution role. Read-only tasks
170
+ * (analysis, explanation, review) get the `READ_ONLY` role so
171
+ * mutation tools are not even visible to the model. This is
172
+ * the dynamic-tool-masking layer of Pillar 5.
173
+ */
174
+ export function classifyExecutionRole(task) {
175
+ const lower = task.toLowerCase();
176
+ // Read-only keywords — the agent must answer a question or
177
+ // describe something, not modify files.
178
+ const readOnlyPatterns = [
179
+ /\b(analy[sz]e|analysing|analysed)\b/,
180
+ /\b(review|auditing|audit)\b/,
181
+ /\b(explain|describe|what does|how does|why does)\b/,
182
+ /\b(vulnerabilit(y|ies)|security review|threat model)\b/,
183
+ /\b(read(ing)? the (entire )?code(base)?)\b/,
184
+ /\b(find (the )?bugs?|find (the )?vulnerabilities|find (the )?issues?)\b/,
185
+ /\b(list(ing)? (the )?files|show (me )?the files|what files)\b/,
186
+ /\b(without (modif|chang|edit|alter)ing)\b/,
187
+ /\b(read[\s-]only)\b/,
188
+ ];
189
+ for (const pattern of readOnlyPatterns) {
190
+ if (pattern.test(lower))
191
+ return 'READ_ONLY';
192
+ }
193
+ return 'BUILD';
194
+ }
195
+ export const TOOL_DEFINITIONS = [
196
+ {
197
+ type: 'function',
198
+ function: {
199
+ name: 'read_file',
200
+ description: 'Read the full text contents of a file at the given path. Use this to understand existing code before making changes. Returns the file contents as a string. Files larger than the large-file gate (15 KiB / 350 lines by default) will return a [Context-Budget Guard] synthetic directive telling you to call extract_symbols or extract_imports first.',
201
+ parameters: {
202
+ type: 'object',
203
+ properties: {
204
+ path: {
205
+ type: 'string',
206
+ description: 'The file path to read, relative to the workspace root or absolute.',
207
+ },
208
+ },
209
+ required: ['path'],
210
+ },
211
+ },
212
+ },
213
+ {
214
+ type: 'function',
215
+ function: {
216
+ name: 'extract_symbols',
217
+ description: 'Extract symbol declarations (classes, functions, interfaces, types, consts) from a file. Output is capped at 100 entries. Cheaper than read_file for large files because it skips the body content.',
218
+ parameters: {
219
+ type: 'object',
220
+ properties: {
221
+ path: {
222
+ type: 'string',
223
+ description: 'The file path to inspect, relative to the workspace root or absolute.',
224
+ },
225
+ },
226
+ required: ['path'],
227
+ },
228
+ },
229
+ },
230
+ {
231
+ type: 'function',
232
+ function: {
233
+ name: 'extract_imports',
234
+ description: 'Extract import statements from a file. Output is capped at 100 entries. Cheaper than read_file for large files because it skips the body content.',
235
+ parameters: {
236
+ type: 'object',
237
+ properties: {
238
+ path: {
239
+ type: 'string',
240
+ description: 'The file path to inspect, relative to the workspace root or absolute.',
241
+ },
242
+ },
243
+ required: ['path'],
244
+ },
245
+ },
246
+ },
247
+ {
248
+ type: 'function',
249
+ function: {
250
+ name: 'apply_patch',
251
+ description: 'Apply a unified diff patch to files in the workspace. Prefer this over write_file for editing existing files.',
252
+ parameters: {
253
+ type: 'object',
254
+ properties: {
255
+ patch: { type: 'string', description: 'Unified diff patch text.' },
256
+ },
257
+ required: ['patch'],
258
+ },
259
+ },
260
+ },
261
+ {
262
+ type: 'function',
263
+ function: {
264
+ name: 'replace_range',
265
+ description: 'Replace inclusive 1-based line range in a file. Requires reading the file first.',
266
+ parameters: {
267
+ type: 'object',
268
+ properties: {
269
+ path: { type: 'string' },
270
+ startLine: { type: 'string' },
271
+ endLine: { type: 'string' },
272
+ content: { type: 'string' },
273
+ },
274
+ required: ['path', 'startLine', 'endLine', 'content'],
275
+ },
276
+ },
277
+ },
278
+ {
279
+ type: 'function',
280
+ function: {
281
+ name: 'insert_after',
282
+ description: 'Insert content after the first exact anchor match in a file. Requires reading the file first.',
283
+ parameters: {
284
+ type: 'object',
285
+ properties: {
286
+ path: { type: 'string' },
287
+ anchor: { type: 'string' },
288
+ content: { type: 'string' },
289
+ },
290
+ required: ['path', 'anchor', 'content'],
291
+ },
292
+ },
293
+ },
294
+ {
295
+ type: 'function',
296
+ function: {
297
+ name: 'rename_file',
298
+ description: 'Rename or move a workspace file.',
299
+ parameters: {
300
+ type: 'object',
301
+ properties: {
302
+ from: { type: 'string' },
303
+ to: { type: 'string' },
304
+ },
305
+ required: ['from', 'to'],
306
+ },
307
+ },
308
+ },
309
+ {
310
+ type: 'function',
311
+ function: {
312
+ name: 'write_file',
313
+ description: 'Write a complete file to disk. Use ONLY for new files or full rewrites where the prior content is irrelevant. For ANY change to an existing file (single-region edit, symbol rename, line tweak, multi-line refactor) you MUST use `str_replace` (single hunk) or `apply_patch` (multi-region) instead — both go through the same atomic staging pipeline but preserve the rest of the file. Rewriting an existing file with write_file when str_replace would do is an error: it wastes tokens, defeats LSP pre-save gating granularity, and risks losing concurrent edits. Creates parent directories if missing.',
314
+ parameters: {
315
+ type: 'object',
316
+ properties: {
317
+ path: {
318
+ type: 'string',
319
+ description: 'The file path to write, relative to the workspace root or absolute.',
320
+ },
321
+ content: {
322
+ type: 'string',
323
+ description: 'The full file content to write.',
324
+ },
325
+ },
326
+ required: ['path', 'content'],
327
+ },
328
+ },
329
+ },
330
+ {
331
+ type: 'function',
332
+ function: {
333
+ name: 'run_command',
334
+ description: 'Execute a shell command and return its stdout and stderr output. Use this to run tests, build projects, install dependencies, or verify changes. Commands run in the workspace directory.',
335
+ parameters: {
336
+ type: 'object',
337
+ properties: {
338
+ command: {
339
+ type: 'string',
340
+ description: 'The shell command to execute.',
341
+ },
342
+ cwd: {
343
+ type: 'string',
344
+ description: 'Working directory for the command (optional, defaults to workspace root).',
345
+ },
346
+ },
347
+ required: ['command'],
348
+ },
349
+ },
350
+ },
351
+ {
352
+ type: 'function',
353
+ function: {
354
+ name: 'run_command_async',
355
+ description: 'Spawn a long-running command in the background and return immediately with a jobId. Use poll_command_status to retrieve output and kill_command to terminate. Output streams cap at 64 KiB each; the larger totalByte counters survive the cap. Rejected in PLAN mode. Workspace-escape and sensitive-file commands are blocked at spawn time by the same command-parser used by run_command.',
356
+ parameters: {
357
+ type: 'object',
358
+ properties: {
359
+ cmd: { type: 'string', description: 'The binary to spawn.' },
360
+ args: {
361
+ type: 'array',
362
+ items: { type: 'string' },
363
+ description: 'Argument vector.',
364
+ },
365
+ cwd: {
366
+ type: 'string',
367
+ description: 'Working directory (defaults to workspace root).',
368
+ },
369
+ },
370
+ required: ['cmd'],
371
+ },
372
+ },
373
+ },
374
+ {
375
+ type: 'function',
376
+ function: {
377
+ name: 'poll_command_status',
378
+ description: 'Read a snapshot of a background job. The snapshot includes status, exitCode, startedAt/exitedAt, and the (capped) stdout and stderr streams. tailLines truncates each stream to its last N lines; sinceBytes returns only the bytes after the given offset on stdout/stderr (delta fields).',
379
+ parameters: {
380
+ type: 'object',
381
+ properties: {
382
+ jobId: { type: 'string', description: 'The jobId returned by run_command_async.' },
383
+ tailLines: { type: 'integer', description: 'Truncate each stream to last N lines.' },
384
+ sinceBytes: { type: 'integer', description: 'Return only bytes after this offset.' },
385
+ },
386
+ required: ['jobId'],
387
+ },
388
+ },
389
+ },
390
+ {
391
+ type: 'function',
392
+ function: {
393
+ name: 'kill_command',
394
+ description: 'Send SIGTERM to a background job. No-op if the job has already exited.',
395
+ parameters: {
396
+ type: 'object',
397
+ properties: {
398
+ jobId: { type: 'string', description: 'The jobId to terminate.' },
399
+ },
400
+ required: ['jobId'],
401
+ },
402
+ },
403
+ },
404
+ {
405
+ type: 'function',
406
+ function: {
407
+ name: 'search_code',
408
+ description: 'Search for a text or regex pattern in workspace files. Returns matching lines with file paths and line numbers. Use this to find where functions, classes, or variables are defined or used.',
409
+ parameters: {
410
+ type: 'object',
411
+ properties: {
412
+ query: {
413
+ type: 'string',
414
+ description: 'The search pattern (plain text or regex).',
415
+ },
416
+ path: {
417
+ type: 'string',
418
+ description: 'Directory or file to search in (optional, defaults to workspace root).',
419
+ },
420
+ file_pattern: {
421
+ type: 'string',
422
+ description: 'Glob pattern to filter files, e.g., "*.ts" or "*.py" (optional).',
423
+ },
424
+ },
425
+ required: ['query'],
426
+ },
427
+ },
428
+ },
429
+ {
430
+ type: 'function',
431
+ function: {
432
+ name: 'list_dir',
433
+ description: 'List files and directories at the given path. Returns names, types (file/dir), and sizes.',
434
+ parameters: {
435
+ type: 'object',
436
+ properties: {
437
+ path: {
438
+ type: 'string',
439
+ description: 'The directory path to list (optional, defaults to workspace root).',
440
+ },
441
+ },
442
+ required: [],
443
+ },
444
+ },
445
+ },
446
+ {
447
+ type: 'function',
448
+ function: {
449
+ name: 'delete_file',
450
+ description: 'Delete a file at the given path from the workspace.',
451
+ parameters: {
452
+ type: 'object',
453
+ properties: {
454
+ path: {
455
+ type: 'string',
456
+ description: 'The file path to delete, relative to the workspace root or absolute.',
457
+ },
458
+ },
459
+ required: ['path'],
460
+ },
461
+ },
462
+ },
463
+ {
464
+ type: 'function',
465
+ function: {
466
+ name: 'create_branch',
467
+ description: 'Create and checkout a new Git branch.',
468
+ parameters: {
469
+ type: 'object',
470
+ properties: {
471
+ branchName: { type: 'string', description: 'The name of the branch to create.' },
472
+ },
473
+ required: ['branchName'],
474
+ },
475
+ },
476
+ },
477
+ {
478
+ type: 'function',
479
+ function: {
480
+ name: 'commit_changes',
481
+ description: 'Stage all current changes and commit them.',
482
+ parameters: {
483
+ type: 'object',
484
+ properties: {
485
+ message: { type: 'string', description: 'The commit message.' },
486
+ },
487
+ required: ['message'],
488
+ },
489
+ },
490
+ },
491
+ {
492
+ type: 'function',
493
+ function: {
494
+ name: 'push_branch',
495
+ description: 'Push the current active branch to origin or custom remote.',
496
+ parameters: {
497
+ type: 'object',
498
+ properties: {
499
+ remote: { type: 'string', description: 'The remote repository name (default: origin).' },
500
+ },
501
+ required: [],
502
+ },
503
+ },
504
+ },
505
+ {
506
+ type: 'function',
507
+ function: {
508
+ name: 'create_pull_request',
509
+ description: 'Create a pull request on GitHub for the current branch.',
510
+ parameters: {
511
+ type: 'object',
512
+ properties: {
513
+ baseBranch: { type: 'string', description: 'The base branch to merge into (default: main).' },
514
+ },
515
+ required: [],
516
+ },
517
+ },
518
+ },
519
+ {
520
+ type: 'function',
521
+ function: {
522
+ name: 'lsp_goto_definition',
523
+ description: 'Find definition coordinates for a symbol at a given 0-indexed line and character position using LSP.',
524
+ parameters: {
525
+ type: 'object',
526
+ properties: {
527
+ path: { type: 'string', description: 'File path containing the symbol.' },
528
+ line: { type: 'integer', description: '0-indexed line number.' },
529
+ character: { type: 'integer', description: '0-indexed character offset.' },
530
+ },
531
+ required: ['path', 'line', 'character'],
532
+ },
533
+ },
534
+ },
535
+ {
536
+ type: 'function',
537
+ function: {
538
+ name: 'lsp_find_references',
539
+ description: 'Find all reference locations for a symbol at a given 0-indexed line and character position using LSP.',
540
+ parameters: {
541
+ type: 'object',
542
+ properties: {
543
+ path: { type: 'string', description: 'File path containing the symbol.' },
544
+ line: { type: 'integer', description: '0-indexed line number.' },
545
+ character: { type: 'integer', description: '0-indexed character offset.' },
546
+ },
547
+ required: ['path', 'line', 'character'],
548
+ },
549
+ },
550
+ },
551
+ {
552
+ type: 'function',
553
+ function: {
554
+ name: 'lsp_hover',
555
+ description: 'Retrieve type information and documentation for a symbol at a given 0-indexed line and character position using LSP.',
556
+ parameters: {
557
+ type: 'object',
558
+ properties: {
559
+ path: { type: 'string', description: 'File path containing the symbol.' },
560
+ line: { type: 'integer', description: '0-indexed line number.' },
561
+ character: { type: 'integer', description: '0-indexed character offset.' },
562
+ },
563
+ required: ['path', 'line', 'character'],
564
+ },
565
+ },
566
+ },
567
+ {
568
+ type: 'function',
569
+ function: {
570
+ name: 'web_fetch',
571
+ description: 'Fetch a webpage using an HTTP GET request and return its content converted to Markdown. Use this to read documentation or external references.',
572
+ parameters: {
573
+ type: 'object',
574
+ properties: {
575
+ url: { type: 'string', description: 'The absolute URL to fetch.' },
576
+ },
577
+ required: ['url'],
578
+ },
579
+ },
580
+ },
581
+ {
582
+ type: 'function',
583
+ function: {
584
+ name: 'web_search',
585
+ description: 'Perform a web search for a given query and return a list of search results as Markdown snippets. Use this to find information on the web.',
586
+ parameters: {
587
+ type: 'object',
588
+ properties: {
589
+ query: { type: 'string', description: 'The search query.' },
590
+ },
591
+ required: ['query'],
592
+ },
593
+ },
594
+ },
595
+ {
596
+ type: 'function',
597
+ function: {
598
+ name: 'str_replace',
599
+ description: 'Use this tool by default for any in-place edit to an existing file. Performs a surgical, atomic replacement of oldString with newString. By default, oldString must be unique within the file (expectUnique=true) — non-unique matches are rejected with a clear error so the caller can narrow the snippet. Pass replaceAll=true to substitute every occurrence. Rejected in PLAN mode. Refused for platform-locked paths. Goes through the same atomic staging pipeline as write_file and the LSP pre-save gate before any disk mutation.',
600
+ parameters: {
601
+ type: 'object',
602
+ properties: {
603
+ path: {
604
+ type: 'string',
605
+ description: 'The file path to edit, relative to the workspace root or absolute.',
606
+ },
607
+ oldString: {
608
+ type: 'string',
609
+ description: 'The exact substring to replace. Must appear in the file.',
610
+ },
611
+ newString: {
612
+ type: 'string',
613
+ description: 'The replacement content.',
614
+ },
615
+ replaceAll: {
616
+ type: 'boolean',
617
+ description: 'If true, replace every occurrence. Default false.',
618
+ },
619
+ expectUnique: {
620
+ type: 'boolean',
621
+ description: 'If true (default), the operation aborts when oldString is not unique. Set to false to allow non-unique matches with the first occurrence replaced.',
622
+ },
623
+ },
624
+ required: ['path', 'oldString', 'newString'],
625
+ },
626
+ },
627
+ },
628
+ {
629
+ type: 'function',
630
+ function: {
631
+ name: 'todo_read',
632
+ description: 'Read the current project todo list from <cwd>/.fixo/todo_list.json. Returns a human-readable rendering of all items grouped into Open and Completed. A missing or unreadable file yields an empty list — by design.',
633
+ parameters: {
634
+ type: 'object',
635
+ properties: {},
636
+ required: [],
637
+ },
638
+ },
639
+ },
640
+ {
641
+ type: 'function',
642
+ function: {
643
+ name: 'todo_write',
644
+ description: 'Mutate the project todo list. Operations: add (content+blockedBy optional), set_status (id+status), remove (id), clear_done. Persisted atomically to <cwd>/.fixo/todo_list.json. Rejected in PLAN mode.',
645
+ parameters: {
646
+ type: 'object',
647
+ properties: {
648
+ op: {
649
+ type: 'string',
650
+ enum: ['add', 'set_status', 'remove', 'clear_done'],
651
+ description: 'The mutation to apply.',
652
+ },
653
+ content: {
654
+ type: 'string',
655
+ description: 'Item content (op=add only).',
656
+ },
657
+ id: {
658
+ type: 'string',
659
+ description: 'Item id (op=set_status, op=remove).',
660
+ },
661
+ status: {
662
+ type: 'string',
663
+ enum: ['pending', 'in_progress', 'done', 'cancelled'],
664
+ description: 'New status (op=set_status only).',
665
+ },
666
+ blockedBy: {
667
+ type: 'string',
668
+ description: 'Optional blocker description (op=add only).',
669
+ },
670
+ },
671
+ required: ['op'],
672
+ },
673
+ },
674
+ },
675
+ {
676
+ type: 'function',
677
+ function: {
678
+ name: 'glob_files',
679
+ description: 'High-performance filesystem pattern matcher. Returns paths matching the given glob pattern, relative to the workspace root. Built on Node 22+ native fs.promises.glob. By default, common build/VCS directories (node_modules, .git, dist, .fixo, .fixocli) are excluded. Symlinks are not followed and hidden files are excluded unless explicitly enabled. Capped at maxResults (default 1000, hard cap 5000).',
680
+ parameters: {
681
+ type: 'object',
682
+ properties: {
683
+ pattern: {
684
+ type: 'string',
685
+ description: 'Glob pattern, e.g. "src/**/*.ts" or "**/package.json".',
686
+ },
687
+ cwd: {
688
+ type: 'string',
689
+ description: 'Optional directory to scope the glob. Must resolve inside the workspace. Defaults to the workspace root.',
690
+ },
691
+ ignore: {
692
+ type: 'string',
693
+ description: 'Optional extra glob pattern (or comma-separated patterns) to add to the default skip set.',
694
+ },
695
+ maxResults: {
696
+ type: 'integer',
697
+ description: 'Maximum number of results to return. Default 1000, hard cap 5000.',
698
+ },
699
+ includeHidden: {
700
+ type: 'boolean',
701
+ description: 'If true, do not exclude dotfile entries from the match. Default false.',
702
+ },
703
+ followSymlinks: {
704
+ type: 'boolean',
705
+ description: 'If true, follow symbolic links during traversal. Default false (safer).',
706
+ },
707
+ },
708
+ required: ['pattern'],
709
+ },
710
+ },
711
+ },
712
+ ];
713
+ /* ──────────────────────── Per-process Run ID (Pillar 2) ──────────── */
714
+ let cachedRunId = null;
715
+ /**
716
+ * Lazily generate a per-process run id. Used to namespace
717
+ * `.fixo/staging/<runId>/` so concurrent runs against the same
718
+ * workspace never collide. The id is a 12-character base36 token.
719
+ */
720
+ export function getOrCreateRunId() {
721
+ if (cachedRunId)
722
+ return cachedRunId;
723
+ cachedRunId = Math.random().toString(36).slice(2, 8) +
724
+ Date.now().toString(36).slice(-6);
725
+ return cachedRunId;
726
+ }
727
+ /** Test/utility hook — reset the cached run id. */
728
+ export function resetRunId() {
729
+ cachedRunId = null;
730
+ }
731
+ /* ──────────────────────── LSP Pre-Save Gate (Pillar 3) ──────────── */
732
+ let cachedLspGate = null;
733
+ /**
734
+ * Lazily construct a singleton {@link LspPreSaveGate} that wires
735
+ * the tool-executor to the live `LspManager`. Mode is read from
736
+ * the user's safety config (default: `'warn'`). The gate is a
737
+ * no-op when no language server is installed on `PATH`, matching
738
+ * the existing `LspManager` behaviour.
739
+ */
740
+ export function getOrCreateLspGate(cwd, safety) {
741
+ if (cachedLspGate)
742
+ return cachedLspGate;
743
+ cachedLspGate = new LspPreSaveGate({
744
+ mode: safety.lspPreSave,
745
+ provider: makeLspProvider(getLspManager(cwd)),
746
+ });
747
+ return cachedLspGate;
748
+ }
749
+ /** Test/utility hook — reset the cached gate. */
750
+ export function resetLspGate() {
751
+ cachedLspGate = null;
752
+ }
753
+ function riskOf(action, detail) {
754
+ if (action === 'command')
755
+ return classifyCommand(detail);
756
+ if (action === 'delete')
757
+ return 'high';
758
+ if (action === 'write')
759
+ return 'medium';
760
+ return 'low';
761
+ }
762
+ function evaluateToolGate(toolName, args, cwd, policy, action, detail) {
763
+ const check = checkPermission(toolName, args, cwd, policy);
764
+ const risk = riskOf(action, detail);
765
+ if (check.decision === 'deny') {
766
+ return {
767
+ allowed: false,
768
+ needsConfirmation: false,
769
+ reason: check.reason,
770
+ risk,
771
+ source: check.source,
772
+ matchedRule: check.matchedRule,
773
+ };
774
+ }
775
+ return {
776
+ allowed: true,
777
+ needsConfirmation: check.decision === 'ask',
778
+ reason: check.reason,
779
+ risk,
780
+ source: check.source,
781
+ matchedRule: check.matchedRule,
782
+ };
783
+ }
784
+ /* ──────────────────────── Atomic Write Helper (Pillar 2) ─────────── */
785
+ /**
786
+ * Stage-and-commit a file write. When `safety.atomicStaging` is
787
+ * true, the new content goes through
788
+ * {@link AtomicStagingManager} so the swap is atomic and the
789
+ * pre-commit hook (Pillar 3) gets a chance to validate. When
790
+ * false, the legacy direct-write path is used and a `null`
791
+ * staging manager is returned to the caller.
792
+ */
793
+ async function applyAtomicWrite(cwd, filePath, content, safety, session) {
794
+ const guard = new WorkspaceGuard(cwd);
795
+ const resolved = guard.resolve(filePath, 'file');
796
+ const existed = fs.existsSync(resolved);
797
+ if (!safety?.atomicStaging) {
798
+ // Legacy path: write directly (preserves the existing diff-printing
799
+ // and session semantics inside the caller).
800
+ const parentDir = path.dirname(resolved);
801
+ if (!fs.existsSync(parentDir)) {
802
+ fs.mkdirSync(parentDir, { recursive: true });
803
+ }
804
+ fs.writeFileSync(resolved, content, 'utf-8');
805
+ session?.noteChange(resolved);
806
+ return {
807
+ result: existed ? `File updated: ${filePath}` : `File created: ${filePath}`,
808
+ staged: false,
809
+ created: !existed,
810
+ };
811
+ }
812
+ const mgr = new AtomicStagingManager(cwd, getOrCreateRunId(), {
813
+ ttlMs: safety.stagingTtlMs,
814
+ // Pillar 3 — wire the LSP pre-save gate as the staging
815
+ // manager's pre-commit hook. The gate runs diagnostics on
816
+ // the staged file; in `block` mode it throws on any
817
+ // error-severity diagnostic, which causes the staging
818
+ // manager to surface a PreCommitHookRejectedError and the
819
+ // user keeps the original file. In `warn` mode the gate
820
+ // just logs via onResult and lets the commit proceed.
821
+ preCommitHook: async (e) => {
822
+ const gate = getOrCreateLspGate(cwd, safety);
823
+ const result = await gate.check(e);
824
+ gate.enforce(result, e);
825
+ },
826
+ // Pillar 5 / Protection 3 — structural syntax health check.
827
+ // JS/TS files are passed through the brace/paren/bracket
828
+ // balance check in `src/lsp/syntax-fallback.ts`. The real
829
+ // TypeScript compile is the LSP gate's job; this is a fast
830
+ // structural sanity check that catches the catastrophic case
831
+ // (unclosed `try` block, dangling `catch` keyword, etc.).
832
+ syntaxHealthCheck: async (e, content) => {
833
+ const lower = e.targetPath.toLowerCase();
834
+ const isJs = lower.endsWith('.js') || lower.endsWith('.cjs') || lower.endsWith('.mjs');
835
+ const isTs = lower.endsWith('.ts') || lower.endsWith('.tsx');
836
+ if (!isJs && !isTs)
837
+ return;
838
+ const verdict = syntaxHealthCheck(content);
839
+ if (verdict.state === 'ok')
840
+ return;
841
+ const e2 = new Error(`Structural syntax check failed for ${path.basename(e.targetPath)}: ` +
842
+ `${formatSyntaxVerdict(verdict)} ` +
843
+ `The staged write was rejected to protect the target file.`);
844
+ e2.code = 'FIXO_STRUCTURAL_SYNTAX';
845
+ throw e2;
846
+ },
847
+ });
848
+ const entry = mgr.stage(filePath, content, 0o644);
849
+ const commit = await mgr.commit(entry.id);
850
+ if (commit.committed) {
851
+ session?.noteChange(resolved);
852
+ }
853
+ return {
854
+ result: existed
855
+ ? `File updated (atomic): ${filePath}`
856
+ : `File created (atomic): ${filePath}`,
857
+ staged: commit.committed,
858
+ created: !existed,
859
+ };
860
+ }
861
+ async function askUnsafeCommandPermission(command, reason, allowWithoutPrompt) {
862
+ if (allowWithoutPrompt) {
863
+ console.log(`\n${colors.yellow}⚠ Security Warning: Executing potentially unsafe command: ${colors.bold}${command}${colors.reset}\nReason: ${reason}`);
864
+ return true;
865
+ }
866
+ console.log(`\n${colors.red}${colors.bold}⚠ SECURITY WARNING:${colors.reset}`);
867
+ console.log(`The agent is attempting to execute a command that violates safety sandboxing:`);
868
+ console.log(`- Command: ${colors.yellow}${command}${colors.reset}`);
869
+ console.log(`- Danger: ${colors.red}${reason}${colors.reset}\n`);
870
+ const confirmed = await p.confirm({
871
+ message: `Do you want to bypass this warning and allow execution?`,
872
+ initialValue: false,
873
+ });
874
+ return !p.isCancel(confirmed) && confirmed;
875
+ }
876
+ /**
877
+ * Execute a tool call and return its result string.
878
+ * Also logs the operation to the terminal with colored output.
879
+ */
880
+ export async function executeTool(name, args, cwd, verbose = false, options = {}) {
881
+ const event = {
882
+ tool: name,
883
+ args,
884
+ result: '',
885
+ isWrite: false,
886
+ };
887
+ try {
888
+ const policy = options.policy ?? options.session?.policy ?? 'shell-confirm';
889
+ // ──── PreToolUse hooks (§3.4) ────
890
+ // Run any user-defined pre-tool hooks. A `deny` decision
891
+ // short-circuits the call; a `modify` decision replaces
892
+ // args after a `WorkspaceGuard` re-check.
893
+ const sessionId = options.session?.id ?? 'no-session';
894
+ const preHook = fireHooks(cwd, 'PreToolUse', {
895
+ tool: name,
896
+ args: args,
897
+ sessionId,
898
+ });
899
+ if (preHook.fired && preHook.decision === 'deny') {
900
+ const reason = preHook.reason ?? 'pre-tool hook denied';
901
+ event.result = `Error: hook denied (${preHook.hookId ?? 'unknown'}): ${reason}`;
902
+ options.session?.record('tool_denied', { tool: name, reason, args });
903
+ renderToolCall({ kind: 'error', name, detail: `hook denied: ${reason}` });
904
+ return event;
905
+ }
906
+ if (preHook.fired && preHook.decision === 'modify' && preHook.modifiedArgs) {
907
+ const applied = applyModifiedArgs(cwd, args, preHook.modifiedArgs);
908
+ if (!applied.ok) {
909
+ const reason = applied.reason ?? 'pre-hook modify rejected';
910
+ event.result = `Error: hook modify rejected: ${reason}`;
911
+ options.session?.record('tool_denied', { tool: name, reason, args });
912
+ renderToolCall({ kind: 'error', name, detail: `hook modify rejected: ${reason}` });
913
+ return event;
914
+ }
915
+ for (const [k, v] of Object.entries(applied.args)) {
916
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
917
+ args[k] = String(v);
918
+ }
919
+ }
920
+ }
921
+ const plugin = loadedPlugins.find(p => p.tools.some(t => t.function.name === name));
922
+ if (plugin) {
923
+ const action = name.includes('read') || name.includes('get') || name.includes('list') || name.includes('view') ? 'read' : 'write';
924
+ const decision = evaluateToolGate(name, args, cwd, policy, action, name);
925
+ if (!decision.allowed) {
926
+ event.result = `Error: ${decision.reason}`;
927
+ options.session?.record('tool_denied', { tool: name, reason: decision.reason, args, matchedRule: decision.matchedRule, source: decision.source });
928
+ return event;
929
+ }
930
+ options.session?.record('tool_started', { tool: name, args, risk: decision.risk, matchedRule: decision.matchedRule, source: decision.source });
931
+ console.log(` ${colors.dim}🔌 Plugin: ${name}${colors.reset}`);
932
+ try {
933
+ event.result = await plugin.execute(name, args, { cwd, verbose, policy, options });
934
+ event.isWrite = action === 'write';
935
+ }
936
+ catch (error) {
937
+ const msg = error instanceof Error ? error.message : String(error);
938
+ event.result = `Error: ${msg}`;
939
+ renderToolCall({ kind: 'error', name, detail: truncate(msg, 80) });
940
+ }
941
+ options.session?.record('tool_finished', { tool: name, result: truncate(event.result, 2000), isWrite: event.isWrite });
942
+ return event;
943
+ }
944
+ if (mcpManager.hasTool(name)) {
945
+ const action = name.includes('read') || name.includes('get') || name.includes('list') || name.includes('view') ? 'read' : 'write';
946
+ const decision = evaluateToolGate(name, args, cwd, policy, action, name);
947
+ if (!decision.allowed) {
948
+ event.result = `Error: ${decision.reason}`;
949
+ options.session?.record('tool_denied', { tool: name, reason: decision.reason, args, matchedRule: decision.matchedRule, source: decision.source });
950
+ return event;
951
+ }
952
+ options.session?.record('tool_started', { tool: name, args, risk: decision.risk, matchedRule: decision.matchedRule, source: decision.source });
953
+ console.log(` ${colors.dim}🔌 MCP: ${name}${colors.reset}`);
954
+ try {
955
+ event.result = await mcpManager.executeTool(name, args);
956
+ event.isWrite = action === 'write';
957
+ }
958
+ catch (error) {
959
+ const msg = error instanceof Error ? error.message : String(error);
960
+ event.result = `Error: ${msg}`;
961
+ renderToolCall({ kind: 'error', name, detail: truncate(msg, 80) });
962
+ }
963
+ options.session?.record('tool_finished', { tool: name, result: truncate(event.result, 2000), isWrite: event.isWrite });
964
+ return event;
965
+ }
966
+ if (mcpBridgeManager.hasTool(name)) {
967
+ const action = name.includes('read') || name.includes('get') || name.includes('list') || name.includes('view') ? 'read' : 'write';
968
+ const decision = evaluateToolGate(name, args, cwd, policy, action, name);
969
+ if (!decision.allowed) {
970
+ event.result = `Error: ${decision.reason}`;
971
+ options.session?.record('tool_denied', { tool: name, reason: decision.reason, args, matchedRule: decision.matchedRule, source: decision.source });
972
+ return event;
973
+ }
974
+ options.session?.record('tool_started', { tool: name, args, risk: decision.risk, matchedRule: decision.matchedRule, source: decision.source });
975
+ console.log(` ${colors.dim}🔌 Local MCP: ${name}${colors.reset}`);
976
+ try {
977
+ event.result = await mcpBridgeManager.executeTool(name, args);
978
+ event.isWrite = action === 'write';
979
+ }
980
+ catch (error) {
981
+ const msg = error instanceof Error ? error.message : String(error);
982
+ event.result = `Error: ${msg}`;
983
+ renderToolCall({ kind: 'error', name, detail: truncate(msg, 80) });
984
+ }
985
+ options.session?.record('tool_finished', { tool: name, result: truncate(event.result, 2000), isWrite: event.isWrite });
986
+ return event;
987
+ }
988
+ const action = name === 'run_command'
989
+ ? 'command'
990
+ : name === 'read_file' || name === 'search_code' || name === 'list_dir' || name === 'web_fetch' || name === 'web_search'
991
+ ? 'read'
992
+ : name === 'delete_file'
993
+ ? 'delete'
994
+ : 'write';
995
+ const policyTargetRaw = name === 'web_fetch' ? args.url : name === 'web_search' ? args.query : (args.command ?? args.path ?? '');
996
+ const policyTarget = typeof policyTargetRaw === 'string' ? policyTargetRaw : String(policyTargetRaw ?? '');
997
+ const decision = evaluateToolGate(name, args, cwd, policy, action, policyTarget);
998
+ if (!decision.allowed) {
999
+ event.result = `Error: ${decision.reason}`;
1000
+ options.session?.record('tool_denied', { tool: name, reason: decision.reason, args, matchedRule: decision.matchedRule, source: decision.source });
1001
+ return event;
1002
+ }
1003
+ options.session?.record('tool_started', { tool: name, args, risk: decision.risk, matchedRule: decision.matchedRule, source: decision.source });
1004
+ switch (name) {
1005
+ case 'read_file': {
1006
+ const guard = new WorkspaceGuard(cwd);
1007
+ const resolved = guard.resolve(args.path, 'file');
1008
+ const budgetPct = options.safety?.predictiveBudgetPct ?? DEFAULT_PREDICTIVE_BUDGET_PCT;
1009
+ // Skip the predictive gate when it is explicitly disabled
1010
+ // (>=1.0) or when no model is configured (we cannot map
1011
+ // model → context window without one).
1012
+ if (budgetPct < 1 && options.model) {
1013
+ const estimate = estimateReadCost(resolved, options.model);
1014
+ const convoTokens = options.getConversationTokens?.() ?? 0;
1015
+ const deferDecision = shouldDeferRead(estimate, convoTokens, options.model, budgetPct);
1016
+ if (deferDecision.defer) {
1017
+ event.result = formatPredictiveGateDirective(args.path, estimate, deferDecision);
1018
+ event.affectedPath = resolved;
1019
+ renderToolCall({ kind: 'read', name: 'Read', detail: `${shortenPath(args.path, cwd)} (deferred — predictive gate)` });
1020
+ options.session?.record('predictive_gate_fired', {
1021
+ path: args.path,
1022
+ projectedTokens: estimate.projectedTokens,
1023
+ projectedTotal: deferDecision.projectedTotal,
1024
+ hardCap: deferDecision.hardCap,
1025
+ });
1026
+ break;
1027
+ }
1028
+ }
1029
+ event.result = executeReadFile(args.path, cwd, options.session, options.safety?.largeFileGateBytes, options.safety?.largeFileGateLines);
1030
+ event.affectedPath = resolved;
1031
+ renderToolCall({ kind: 'read', name: 'Read', detail: shortenPath(args.path, cwd) });
1032
+ break;
1033
+ }
1034
+ case 'extract_symbols':
1035
+ event.result = await executeExtractSymbols(args.path, cwd, options.session);
1036
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1037
+ renderToolCall({ kind: 'read', name: 'Symbols', detail: shortenPath(args.path, cwd) });
1038
+ break;
1039
+ case 'extract_imports':
1040
+ event.result = await executeExtractImports(args.path, cwd, options.session);
1041
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1042
+ renderToolCall({ kind: 'read', name: 'Imports', detail: shortenPath(args.path, cwd) });
1043
+ break;
1044
+ case 'write_file':
1045
+ event.result = await executeWriteFile(args.path, args.content, cwd, options);
1046
+ event.isWrite = true;
1047
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1048
+ renderToolCall({ kind: 'write', name: 'Write', detail: shortenPath(args.path, cwd) });
1049
+ break;
1050
+ case 'run_command':
1051
+ renderToolCall({ kind: 'bash', name: 'Run', detail: truncate(args.command, 60) });
1052
+ let safetyResult = { safe: true, reason: '' };
1053
+ try {
1054
+ const { isCommandSafe } = await import('./command-parser.js');
1055
+ const safety = await isCommandSafe(args.command, cwd);
1056
+ if (!safety.safe) {
1057
+ safetyResult = { safe: false, reason: safety.reason || 'Unsafe command detected' };
1058
+ }
1059
+ }
1060
+ catch (err) {
1061
+ if (verbose) {
1062
+ console.error('Failed to run AST command safety check, falling back to regex safety check:', err.message);
1063
+ }
1064
+ const trimmed = args.command.trim();
1065
+ const { DANGEROUS_COMMANDS } = await import('../runtime/policy.js');
1066
+ for (const pattern of DANGEROUS_COMMANDS) {
1067
+ if (pattern.test(trimmed)) {
1068
+ safetyResult = {
1069
+ safe: false,
1070
+ reason: `Regex security match: Command contains potentially unsafe pattern/metacharacter: ${pattern.toString()}`
1071
+ };
1072
+ break;
1073
+ }
1074
+ }
1075
+ }
1076
+ if (!safetyResult.safe) {
1077
+ const allowed = await askUnsafeCommandPermission(args.command, safetyResult.reason, options.allowWithoutPrompt);
1078
+ if (!allowed) {
1079
+ event.result = `Error: Security block - Execution denied for unsafe command: ${safetyResult.reason}`;
1080
+ break;
1081
+ }
1082
+ }
1083
+ event.result = executeRunCommand(args.command, args.cwd || cwd, cwd, options.session);
1084
+ break;
1085
+ case 'search_code':
1086
+ renderToolCall({ kind: 'search', name: 'Search', detail: `"${truncate(args.query, 40)}" in ${args.path ?? '.'}` });
1087
+ event.result = executeSearchCode(args.query, args.path, args.file_pattern, cwd);
1088
+ break;
1089
+ case 'list_dir':
1090
+ renderToolCall({ kind: 'read', name: 'List', detail: args.path ?? '.' });
1091
+ event.result = executeListDir(args.path, cwd);
1092
+ break;
1093
+ case 'delete_file':
1094
+ renderToolCall({ kind: 'write', name: 'Delete', detail: shortenPath(args.path, cwd) });
1095
+ event.result = executeDeleteFile(args.path, cwd, options.session);
1096
+ event.isWrite = true;
1097
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1098
+ break;
1099
+ case 'apply_patch':
1100
+ renderToolCall({ kind: 'write', name: 'Patch', detail: 'unified diff' });
1101
+ event.result = await executeApplyPatch(args.patch, cwd, options);
1102
+ event.isWrite = true;
1103
+ break;
1104
+ case 'replace_range':
1105
+ renderToolCall({ kind: 'write', name: 'Replace', detail: shortenPath(args.path, cwd) });
1106
+ event.result = await executeReplaceRange(args.path, Number(args.startLine), Number(args.endLine), args.content, cwd, options);
1107
+ event.isWrite = true;
1108
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1109
+ break;
1110
+ case 'insert_after':
1111
+ renderToolCall({ kind: 'write', name: 'Insert', detail: shortenPath(args.path, cwd) });
1112
+ event.result = await executeInsertAfter(args.path, args.anchor, args.content, cwd, options);
1113
+ event.isWrite = true;
1114
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1115
+ break;
1116
+ case 'rename_file':
1117
+ renderToolCall({ kind: 'write', name: 'Rename', detail: `${args.from} -> ${args.to}` });
1118
+ event.result = await executeRenameFile(args.from, args.to, cwd, options);
1119
+ event.isWrite = true;
1120
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.to, 'file');
1121
+ break;
1122
+ case 'create_branch':
1123
+ renderToolCall({ kind: 'write', name: 'Branch', detail: args.branchName });
1124
+ event.result = createBranch(cwd, args.branchName);
1125
+ event.isWrite = true;
1126
+ break;
1127
+ case 'commit_changes':
1128
+ renderToolCall({ kind: 'write', name: 'Commit', detail: truncate(args.message, 60) });
1129
+ event.result = commitChanges(cwd, args.message);
1130
+ event.isWrite = true;
1131
+ break;
1132
+ case 'push_branch':
1133
+ renderToolCall({ kind: 'write', name: 'Push', detail: args.remote || 'origin' });
1134
+ event.result = pushBranch(cwd, args.remote || 'origin');
1135
+ event.isWrite = true;
1136
+ break;
1137
+ case 'create_pull_request':
1138
+ renderToolCall({ kind: 'write', name: 'PR', detail: `base: ${args.baseBranch || 'main'}` });
1139
+ if (!options.client) {
1140
+ throw new Error('Agent client is required to generate pull request description');
1141
+ }
1142
+ event.result = await createPullRequest(cwd, options.client, options.model || 'auto', args.baseBranch || 'main');
1143
+ event.isWrite = true;
1144
+ break;
1145
+ case 'lsp_goto_definition': {
1146
+ const line = Number(args.line);
1147
+ const char = Number(args.character);
1148
+ const fileBasename = path.basename(args.path);
1149
+ renderToolCall({ kind: 'read', name: 'Definition', detail: `${fileBasename}:${line}:${char}` });
1150
+ const manager = getLspManager(cwd);
1151
+ const resolvedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1152
+ const def = await manager.gotoDefinition(resolvedPath, line, char);
1153
+ event.result = JSON.stringify(def || null, null, 2);
1154
+ break;
1155
+ }
1156
+ case 'lsp_find_references': {
1157
+ const line = Number(args.line);
1158
+ const char = Number(args.character);
1159
+ const fileBasename = path.basename(args.path);
1160
+ renderToolCall({ kind: 'read', name: 'References', detail: `${fileBasename}:${line}:${char}` });
1161
+ const manager = getLspManager(cwd);
1162
+ const resolvedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1163
+ const refs = await manager.findReferences(resolvedPath, line, char);
1164
+ event.result = JSON.stringify(refs || null, null, 2);
1165
+ break;
1166
+ }
1167
+ case 'lsp_hover': {
1168
+ const line = Number(args.line);
1169
+ const char = Number(args.character);
1170
+ const fileBasename = path.basename(args.path);
1171
+ renderToolCall({ kind: 'read', name: 'Hover', detail: `${fileBasename}:${line}:${char}` });
1172
+ const manager = getLspManager(cwd);
1173
+ const resolvedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1174
+ const hoverRes = await manager.hover(resolvedPath, line, char);
1175
+ event.result = JSON.stringify(hoverRes || null, null, 2);
1176
+ break;
1177
+ }
1178
+ case 'web_fetch':
1179
+ renderToolCall({ kind: 'search', name: 'Fetch', detail: args.url });
1180
+ event.result = await webFetch(args.url);
1181
+ break;
1182
+ case 'web_search':
1183
+ renderToolCall({ kind: 'search', name: 'Search', detail: truncate(args.query, 40) });
1184
+ event.result = await webSearch(args.query);
1185
+ break;
1186
+ case 'str_replace':
1187
+ renderToolCall({ kind: 'write', name: 'Surgical', detail: shortenPath(args.path, cwd) });
1188
+ event.result = await executeStrReplace(args, cwd, options);
1189
+ event.isWrite = true;
1190
+ event.affectedPath = new WorkspaceGuard(cwd).resolve(args.path, 'file');
1191
+ break;
1192
+ case 'glob_files':
1193
+ renderToolCall({ kind: 'search', name: 'Glob', detail: truncate(args.pattern, 60) });
1194
+ event.result = await executeGlobFiles(args, cwd, options);
1195
+ break;
1196
+ case 'todo_read':
1197
+ renderToolCall({ kind: 'search', name: 'Todo', detail: 'read' });
1198
+ event.result = executeTodoRead(cwd);
1199
+ break;
1200
+ case 'todo_write':
1201
+ renderToolCall({ kind: 'write', name: 'Todo', detail: String(args.op ?? 'add') });
1202
+ event.result = await executeTodoWrite(args, cwd, options);
1203
+ event.isWrite = true;
1204
+ break;
1205
+ case 'run_command_async':
1206
+ renderToolCall({ kind: 'bash', name: 'Async', detail: truncate(args.cmd, 30) });
1207
+ event.result = await executeRunCommandAsync(args, cwd, options);
1208
+ break;
1209
+ case 'poll_command_status':
1210
+ renderToolCall({ kind: 'bash', name: 'Poll', detail: String(args.jobId ?? '?') });
1211
+ event.result = executePollCommandStatus(args, cwd);
1212
+ break;
1213
+ case 'kill_command':
1214
+ renderToolCall({ kind: 'bash', name: 'Kill', detail: String(args.jobId ?? '?') });
1215
+ event.result = executeKillCommand(args, cwd);
1216
+ break;
1217
+ default:
1218
+ event.result = `Error: Unknown tool "${name}"`;
1219
+ }
1220
+ }
1221
+ catch (error) {
1222
+ const msg = error instanceof Error ? error.message : String(error);
1223
+ event.result = `Error: ${msg}`;
1224
+ renderToolCall({ kind: 'error', name, detail: truncate(msg, 80) });
1225
+ }
1226
+ options.session?.record('tool_finished', { tool: name, result: truncate(event.result, 2000), isWrite: event.isWrite });
1227
+ // ──── PostToolUse hooks (§3.4) ────
1228
+ // Fire any user-defined post-tool hooks. The tool has
1229
+ // already executed; we only emit telemetry + honour a
1230
+ // `deny` decision by appending a marker to the result so
1231
+ // the caller knows the post-hook flagged it.
1232
+ const postHook = fireHooks(cwd, 'PostToolUse', {
1233
+ tool: name,
1234
+ args: args,
1235
+ sessionId: options.session?.id ?? 'no-session',
1236
+ });
1237
+ if (postHook.fired && postHook.decision === 'deny') {
1238
+ const reason = postHook.reason ?? 'post-tool hook denied';
1239
+ event.result = `${event.result}\n[post-hook denied: ${reason}]`;
1240
+ }
1241
+ return event;
1242
+ }
1243
+ /* ──────────────────────── Tool Implementations ──────────────────────── */
1244
+ function executeReadFile(filePath, cwd, session, largeFileGateBytes = 15 * 1024, largeFileGateLines = 350) {
1245
+ const guard = new WorkspaceGuard(cwd);
1246
+ const resolved = guard.resolve(filePath, 'file');
1247
+ if (!fs.existsSync(resolved)) {
1248
+ return `Error: File not found: ${filePath}`;
1249
+ }
1250
+ const stat = fs.statSync(resolved);
1251
+ if (stat.isDirectory()) {
1252
+ return `Error: "${filePath}" is a directory, not a file. Use list_dir instead.`;
1253
+ }
1254
+ // Skip binary files
1255
+ if (stat.size > 500_000) {
1256
+ return `Error: File is too large (${(stat.size / 1024).toFixed(0)} KB). Read a smaller file or search for specific content.`;
1257
+ }
1258
+ if (guard.isBinaryFile(resolved)) {
1259
+ return `Error: File appears to be binary: ${filePath}`;
1260
+ }
1261
+ // Pillar 3 — Context-Budget Guard. When a file exceeds either
1262
+ // the byte or line gate we refuse to return the full body and
1263
+ // instead hand the LLM a synthetic directive telling it to use
1264
+ // the structural pre-scan tools (`extract_symbols` /
1265
+ // `extract_imports`) instead. This prevents an LLM from
1266
+ // dumping half the workspace into the context window.
1267
+ if (stat.size > largeFileGateBytes ||
1268
+ // Read just enough to count lines without holding the file in
1269
+ // memory twice. `readFileSync` is unavoidable for the content
1270
+ // path below, so accept the cost here.
1271
+ countLines(resolved) > largeFileGateLines) {
1272
+ return buildContextBudgetGuardDirective(resolved, stat.size, largeFileGateBytes, largeFileGateLines);
1273
+ }
1274
+ const content = fs.readFileSync(resolved, 'utf-8');
1275
+ session?.noteRead(resolved);
1276
+ return content;
1277
+ }
1278
+ /**
1279
+ * Count lines in a file. Uses a streaming read so a 500 KB binary
1280
+ * that has slipped past `isBinaryFile` cannot OOM the process.
1281
+ */
1282
+ function countLines(filePath) {
1283
+ let count = 0;
1284
+ let sawNewline = true;
1285
+ const stream = fs.openSync(filePath, 'r');
1286
+ try {
1287
+ const buf = Buffer.allocUnsafe(64 * 1024);
1288
+ let bytesRead = 0;
1289
+ while ((bytesRead = fs.readSync(stream, buf, 0, buf.length, null)) > 0) {
1290
+ for (let i = 0; i < bytesRead; i++) {
1291
+ if (buf[i] === 0x0a) {
1292
+ count++;
1293
+ sawNewline = true;
1294
+ }
1295
+ else {
1296
+ sawNewline = false;
1297
+ }
1298
+ }
1299
+ }
1300
+ if (!sawNewline)
1301
+ count++;
1302
+ }
1303
+ finally {
1304
+ fs.closeSync(stream);
1305
+ }
1306
+ return count;
1307
+ }
1308
+ /**
1309
+ * Build the [Context-Budget Guard] directive returned to the LLM
1310
+ * when `read_file` would otherwise flood the context window.
1311
+ */
1312
+ function buildContextBudgetGuardDirective(resolved, bytes, byteLimit, lineLimit) {
1313
+ const relPath = path.relative(process.cwd(), resolved) || resolved;
1314
+ return (`[Context-Budget Guard] File '${relPath}' is ${(bytes / 1024).toFixed(1)} KiB ` +
1315
+ `(> ${(byteLimit / 1024).toFixed(0)} KiB) or exceeds ${lineLimit} lines. ` +
1316
+ `Full body suppressed to protect the context window. ` +
1317
+ `Call extract_symbols(path='${relPath}') to list top-level declarations, ` +
1318
+ `or extract_imports(path='${relPath}') to list dependencies, before ` +
1319
+ `narrowing your read with a tool like search_code.`);
1320
+ }
1321
+ /**
1322
+ * Resolve a file's `LanguageId` from its extension. Falls back to
1323
+ * 'generic' for unrecognised suffixes.
1324
+ */
1325
+ function resolveLanguageId(filePath) {
1326
+ return languageIdFromExtension(path.extname(filePath));
1327
+ }
1328
+ async function executeExtractSymbols(filePath, cwd, session) {
1329
+ const guard = new WorkspaceGuard(cwd);
1330
+ const resolved = guard.resolve(filePath, 'file');
1331
+ if (!fs.existsSync(resolved)) {
1332
+ return `Error: File not found: ${filePath}`;
1333
+ }
1334
+ if (guard.isBinaryFile(resolved)) {
1335
+ return `Error: File appears to be binary: ${filePath}`;
1336
+ }
1337
+ const content = fs.readFileSync(resolved, 'utf-8');
1338
+ const language = resolveLanguageId(resolved);
1339
+ const parser = await ParserFactory.getParser();
1340
+ const symbols = parser.extractSymbols(content, language);
1341
+ session?.noteStructuralMap?.(resolved, { symbols: true, imports: false });
1342
+ if (symbols.length === 0) {
1343
+ return `No symbols detected in '${filePath}' (language=${language}).`;
1344
+ }
1345
+ const lines = symbols.map((s) => `- [${s.kind}${s.exported ? ', exported' : ''}] ${s.name} (line ${s.line})`);
1346
+ return `Symbols in '${filePath}' (${symbols.length}):\n${lines.join('\n')}`;
1347
+ }
1348
+ async function executeExtractImports(filePath, cwd, session) {
1349
+ const guard = new WorkspaceGuard(cwd);
1350
+ const resolved = guard.resolve(filePath, 'file');
1351
+ if (!fs.existsSync(resolved)) {
1352
+ return `Error: File not found: ${filePath}`;
1353
+ }
1354
+ if (guard.isBinaryFile(resolved)) {
1355
+ return `Error: File appears to be binary: ${filePath}`;
1356
+ }
1357
+ const content = fs.readFileSync(resolved, 'utf-8');
1358
+ const language = resolveLanguageId(resolved);
1359
+ const parser = await ParserFactory.getParser();
1360
+ const imports = parser.extractImports(content, language);
1361
+ session?.noteStructuralMap?.(resolved, { symbols: false, imports: true });
1362
+ if (imports.length === 0) {
1363
+ return `No imports detected in '${filePath}' (language=${language}).`;
1364
+ }
1365
+ const lines = imports.map((i) => {
1366
+ const tag = i.isTypeOnly ? ' [type-only]' : '';
1367
+ const syms = i.symbols.length > 0 ? ` {${i.symbols.join(', ')}}` : '';
1368
+ return `- '${i.source}'${tag}${syms} (line ${i.line})`;
1369
+ });
1370
+ return `Imports in '${filePath}' (${imports.length}):\n${lines.join('\n')}`;
1371
+ }
1372
+ function executeWriteFile(filePath, content, cwd, options = {}) {
1373
+ const guard = new WorkspaceGuard(cwd);
1374
+ const resolved = guard.resolve(filePath, 'file');
1375
+ // Pillar 5 / Protection 1 — refuse to mutate the platform's
1376
+ // own runtime. This is the guard that prevents an autonomous
1377
+ // agent from corrupting `src/agent/tool-executor.ts` and
1378
+ // breaking its own host.
1379
+ try {
1380
+ guard.assertNotPlatformPath(resolved);
1381
+ }
1382
+ catch (err) {
1383
+ if (err instanceof PlatformPathLockedError) {
1384
+ return Promise.resolve(err.message);
1385
+ }
1386
+ throw err;
1387
+ }
1388
+ const mutation = options.session?.canMutate(resolved);
1389
+ if (mutation && !mutation.ok)
1390
+ return Promise.resolve(`Error: ${mutation.reason}`);
1391
+ options.session?.captureBefore(resolved);
1392
+ const existed = fs.existsSync(resolved);
1393
+ return applyAtomicWrite(cwd, filePath, content, options.safety, options.session)
1394
+ .then(() => {
1395
+ if (!existed)
1396
+ return `File created: ${filePath}`;
1397
+ // Existing file — print the diff for the user.
1398
+ try {
1399
+ const relativePath = guard.relative(resolved);
1400
+ const result = spawnSync('git', ['diff', '--color=always', '--', relativePath], { cwd, encoding: 'utf-8' });
1401
+ if (result.status === 0 && result.stdout) {
1402
+ const diffOutput = result.stdout.trim();
1403
+ if (diffOutput) {
1404
+ console.log(`\n${colors.cyan}--- File Changes Diff ---${colors.reset}`);
1405
+ const lines = diffOutput.split('\n');
1406
+ if (lines.length > 50) {
1407
+ console.log(lines.slice(0, 48).join('\n') + `\n${colors.yellow}... (diff truncated)${colors.reset}`);
1408
+ }
1409
+ else {
1410
+ console.log(diffOutput);
1411
+ }
1412
+ console.log(`${colors.cyan}-------------------------${colors.reset}\n`);
1413
+ }
1414
+ }
1415
+ }
1416
+ catch {
1417
+ // Fail-safe diff printing
1418
+ }
1419
+ return `File updated: ${filePath}`;
1420
+ });
1421
+ }
1422
+ function executeRunCommand(command, requestedCwd, workspaceRoot, session) {
1423
+ const guard = new WorkspaceGuard(workspaceRoot);
1424
+ const commandCwd = guard.resolve(requestedCwd, 'command cwd');
1425
+ try {
1426
+ const result = spawnSync(command, {
1427
+ shell: true,
1428
+ cwd: commandCwd,
1429
+ encoding: 'utf-8',
1430
+ timeout: 60_000, // 60 second timeout
1431
+ maxBuffer: 1024 * 1024, // 1MB max output
1432
+ env: redactedEnv(),
1433
+ });
1434
+ const output = redactSecrets([result.stdout ?? '', result.stderr ?? ''].filter(Boolean).join('\n'));
1435
+ const status = result.status ?? 0;
1436
+ session?.record('command_finished', { command, cwd: guard.relative(commandCwd), status, output: truncate(output, 4000) });
1437
+ return output || `(command completed with code ${status})`;
1438
+ }
1439
+ catch (error) {
1440
+ const stdout = error.stdout ?? '';
1441
+ const stderr = error.stderr ?? '';
1442
+ const code = error.status ?? 'unknown';
1443
+ return redactSecrets(`Command exited with code ${code}\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`.trim());
1444
+ }
1445
+ }
1446
+ function executeSearchCode(query, searchPath, filePattern, cwd) {
1447
+ const guard = new WorkspaceGuard(cwd);
1448
+ const targetDir = searchPath ? guard.resolve(searchPath, 'search path') : cwd;
1449
+ // Try ripgrep first
1450
+ let hasRg = false;
1451
+ try {
1452
+ const which = spawnSync('which', ['rg'], { encoding: 'utf-8' });
1453
+ if (which.status === 0 && which.stdout.trim()) {
1454
+ hasRg = true;
1455
+ }
1456
+ }
1457
+ catch (error) {
1458
+ if (process.env.DEBUG || process.env.VERBOSE || process.argv.includes('--verbose')) {
1459
+ console.warn(`[Debug Warning] Failed to determine if ripgrep (rg) is installed: ${error.message || error}`);
1460
+ }
1461
+ }
1462
+ let output = '';
1463
+ if (hasRg) {
1464
+ const args = ['-n', '--no-heading', '--color', 'never', query];
1465
+ if (filePattern) {
1466
+ args.push('-g', filePattern);
1467
+ }
1468
+ args.push(targetDir);
1469
+ const result = spawnSync('rg', args, {
1470
+ encoding: 'utf-8',
1471
+ cwd,
1472
+ maxBuffer: 512 * 1024,
1473
+ timeout: 15000,
1474
+ });
1475
+ output = result.stdout ?? '';
1476
+ }
1477
+ else {
1478
+ // Fallback to grep
1479
+ const args = ['-rn', query];
1480
+ if (filePattern) {
1481
+ args.push(`--include=${filePattern}`);
1482
+ }
1483
+ args.push(targetDir);
1484
+ const result = spawnSync('grep', args, {
1485
+ encoding: 'utf-8',
1486
+ cwd,
1487
+ maxBuffer: 512 * 1024,
1488
+ timeout: 15000,
1489
+ });
1490
+ output = result.stdout ?? '';
1491
+ }
1492
+ if (!output.trim()) {
1493
+ return `No matches found for "${query}"`;
1494
+ }
1495
+ // Make paths relative to workspace and limit to 50 results
1496
+ const lines = output.trim().split('\n').slice(0, 50).map((line) => {
1497
+ if (line.startsWith(cwd)) {
1498
+ return line.slice(cwd.length + 1);
1499
+ }
1500
+ return line;
1501
+ });
1502
+ return lines.join('\n');
1503
+ }
1504
+ function executeListDir(dirPath, cwd) {
1505
+ const guard = new WorkspaceGuard(cwd);
1506
+ const resolved = dirPath ? guard.resolve(dirPath, 'directory') : cwd;
1507
+ if (!fs.existsSync(resolved)) {
1508
+ return `Error: Directory not found: ${dirPath ?? '.'}`;
1509
+ }
1510
+ const stat = fs.statSync(resolved);
1511
+ if (!stat.isDirectory()) {
1512
+ return `Error: "${dirPath}" is a file, not a directory. Use read_file instead.`;
1513
+ }
1514
+ let entries;
1515
+ try {
1516
+ entries = fs.readdirSync(resolved, { withFileTypes: true });
1517
+ }
1518
+ catch (error) {
1519
+ return `Error: Cannot read directory: ${error instanceof Error ? error.message : String(error)}`;
1520
+ }
1521
+ // Filter and sort
1522
+ const filtered = entries
1523
+ .filter((e) => !e.name.startsWith('.') || e.name === '.env.example')
1524
+ .filter((e) => e.name !== 'node_modules')
1525
+ .sort((a, b) => {
1526
+ if (a.isDirectory() !== b.isDirectory())
1527
+ return a.isDirectory() ? -1 : 1;
1528
+ return a.name.localeCompare(b.name);
1529
+ });
1530
+ const lines = [];
1531
+ for (const entry of filtered) {
1532
+ if (entry.isDirectory()) {
1533
+ lines.push(`📁 ${entry.name}/`);
1534
+ }
1535
+ else {
1536
+ let size = '';
1537
+ try {
1538
+ const s = fs.statSync(path.join(resolved, entry.name));
1539
+ size = formatSize(s.size);
1540
+ }
1541
+ catch {
1542
+ // Ignore
1543
+ }
1544
+ lines.push(` ${entry.name}${size ? ` (${size})` : ''}`);
1545
+ }
1546
+ }
1547
+ return lines.join('\n') || '(empty directory)';
1548
+ }
1549
+ /* ──────────────────────── Helpers ──────────────────────── */
1550
+ function resolvePath(filePath, cwd) {
1551
+ if (path.isAbsolute(filePath))
1552
+ return filePath;
1553
+ return path.resolve(cwd, filePath);
1554
+ }
1555
+ function shortenPath(filePath, cwd) {
1556
+ try {
1557
+ const guard = new WorkspaceGuard(cwd);
1558
+ return guard.relative(guard.resolve(filePath, 'path'));
1559
+ }
1560
+ catch {
1561
+ return filePath;
1562
+ }
1563
+ }
1564
+ function truncate(text, maxLen) {
1565
+ if (text.length <= maxLen)
1566
+ return text;
1567
+ return text.slice(0, maxLen - 1) + '…';
1568
+ }
1569
+ function formatSize(bytes) {
1570
+ if (bytes < 1024)
1571
+ return `${bytes}B`;
1572
+ if (bytes < 1024 * 1024)
1573
+ return `${(bytes / 1024).toFixed(1)}KB`;
1574
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1575
+ }
1576
+ function executeDeleteFile(filePath, cwd, session) {
1577
+ const guard = new WorkspaceGuard(cwd);
1578
+ const resolved = guard.resolve(filePath, 'file');
1579
+ const mutation = session?.canMutate(resolved);
1580
+ if (mutation && !mutation.ok)
1581
+ return `Error: ${mutation.reason}`;
1582
+ session?.captureBefore(resolved);
1583
+ if (!fs.existsSync(resolved)) {
1584
+ return `Error: File not found: ${filePath}`;
1585
+ }
1586
+ const stat = fs.statSync(resolved);
1587
+ if (stat.isDirectory()) {
1588
+ return `Error: "${filePath}" is a directory. delete_file can only delete files.`;
1589
+ }
1590
+ fs.unlinkSync(resolved);
1591
+ session?.noteChange(resolved);
1592
+ return `File deleted: ${filePath}`;
1593
+ }
1594
+ function executeApplyPatch(patch, cwd, options = {}) {
1595
+ if (!patch?.trim())
1596
+ return Promise.resolve('Error: patch is required.');
1597
+ const guard = new WorkspaceGuard(cwd);
1598
+ // Pillar 5 — refuse to apply patches that target platform
1599
+ // runtime files. The `git apply` invocation would otherwise
1600
+ // succeed in corrupting our own source.
1601
+ for (const file of filesFromPatch(patch)) {
1602
+ try {
1603
+ const resolved = guard.resolve(file, 'patch target');
1604
+ guard.assertNotPlatformPath(resolved);
1605
+ }
1606
+ catch (err) {
1607
+ if (err instanceof PlatformPathLockedError) {
1608
+ return Promise.resolve(err.message);
1609
+ }
1610
+ if (err instanceof Error) {
1611
+ return Promise.resolve(`Error: ${err.message}`);
1612
+ }
1613
+ throw err;
1614
+ }
1615
+ }
1616
+ for (const file of filesFromPatch(patch)) {
1617
+ try {
1618
+ options.session?.captureBefore(file);
1619
+ }
1620
+ catch { /* best effort */ }
1621
+ }
1622
+ const result = spawnSync('git', ['apply', '--whitespace=nowarn', '-'], {
1623
+ cwd,
1624
+ input: patch,
1625
+ encoding: 'utf-8',
1626
+ timeout: 30_000,
1627
+ maxBuffer: 1024 * 1024,
1628
+ });
1629
+ if (result.status !== 0)
1630
+ return Promise.resolve(`Patch failed:\n${result.stderr || result.stdout}`);
1631
+ for (const file of filesFromPatch(patch)) {
1632
+ try {
1633
+ options.session?.noteChange(file);
1634
+ }
1635
+ catch { /* best effort */ }
1636
+ }
1637
+ return Promise.resolve('Patch applied.');
1638
+ }
1639
+ function executeReplaceRange(filePath, startLine, endLine, content, cwd, options = {}) {
1640
+ const guard = new WorkspaceGuard(cwd);
1641
+ const resolved = guard.resolve(filePath, 'file');
1642
+ try {
1643
+ guard.assertNotPlatformPath(resolved);
1644
+ }
1645
+ catch (err) {
1646
+ if (err instanceof PlatformPathLockedError)
1647
+ return Promise.resolve(err.message);
1648
+ throw err;
1649
+ }
1650
+ const mutation = options.session?.canMutate(resolved);
1651
+ if (mutation && !mutation.ok)
1652
+ return Promise.resolve(`Error: ${mutation.reason}`);
1653
+ options.session?.captureBefore(resolved);
1654
+ const original = fs.readFileSync(resolved, 'utf-8');
1655
+ const lines = original.split('\n');
1656
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine || endLine > lines.length) {
1657
+ return Promise.resolve(`Error: invalid line range ${startLine}-${endLine}.`);
1658
+ }
1659
+ lines.splice(startLine - 1, endLine - startLine + 1, ...content.split('\n'));
1660
+ return applyAtomicWrite(cwd, filePath, lines.join('\n'), options.safety, options.session)
1661
+ .then(() => `Replaced ${filePath}:${startLine}-${endLine}.`);
1662
+ }
1663
+ function executeInsertAfter(filePath, anchor, content, cwd, options = {}) {
1664
+ const guard = new WorkspaceGuard(cwd);
1665
+ const resolved = guard.resolve(filePath, 'file');
1666
+ try {
1667
+ guard.assertNotPlatformPath(resolved);
1668
+ }
1669
+ catch (err) {
1670
+ if (err instanceof PlatformPathLockedError)
1671
+ return Promise.resolve(err.message);
1672
+ throw err;
1673
+ }
1674
+ const mutation = options.session?.canMutate(resolved);
1675
+ if (mutation && !mutation.ok)
1676
+ return Promise.resolve(`Error: ${mutation.reason}`);
1677
+ options.session?.captureBefore(resolved);
1678
+ const original = fs.readFileSync(resolved, 'utf-8');
1679
+ const idx = original.indexOf(anchor);
1680
+ if (idx === -1)
1681
+ return Promise.resolve(`Error: anchor not found in ${filePath}.`);
1682
+ const insertAt = idx + anchor.length;
1683
+ const next = original.slice(0, insertAt) + content + original.slice(insertAt);
1684
+ return applyAtomicWrite(cwd, filePath, next, options.safety, options.session)
1685
+ .then(() => `Inserted content in ${filePath}.`);
1686
+ }
1687
+ function executeRenameFile(from, to, cwd, options = {}) {
1688
+ const guard = new WorkspaceGuard(cwd);
1689
+ const source = guard.resolve(from, 'source file');
1690
+ const target = guard.resolve(to, 'target file');
1691
+ try {
1692
+ guard.assertNotPlatformPath(source);
1693
+ guard.assertNotPlatformPath(target);
1694
+ }
1695
+ catch (err) {
1696
+ if (err instanceof PlatformPathLockedError)
1697
+ return Promise.resolve(err.message);
1698
+ throw err;
1699
+ }
1700
+ const mutation = options.session?.canMutate(source);
1701
+ if (mutation && !mutation.ok)
1702
+ return Promise.resolve(`Error: ${mutation.reason}`);
1703
+ options.session?.captureBefore(source);
1704
+ options.session?.captureBefore(target);
1705
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1706
+ fs.renameSync(source, target);
1707
+ options.session?.noteChange(source);
1708
+ options.session?.noteChange(target);
1709
+ return Promise.resolve(`Renamed ${from} -> ${to}.`);
1710
+ }
1711
+ function filesFromPatch(patch) {
1712
+ const files = new Set();
1713
+ for (const line of patch.split('\n')) {
1714
+ if (line.startsWith('+++ b/'))
1715
+ files.add(line.slice(6));
1716
+ else if (line.startsWith('--- a/'))
1717
+ files.add(line.slice(6));
1718
+ }
1719
+ return Array.from(files).filter(file => file !== '/dev/null');
1720
+ }
1721
+ /* ──────────────────── Typed errors ──────────────────── */
1722
+ /**
1723
+ * Thrown by the `str_replace` tool when the replacement cannot be
1724
+ * applied. The error is surfaced to the model as a structured tool
1725
+ * result so the next turn can react to the precise reason.
1726
+ */
1727
+ export class SurgicalReplaceError extends Error {
1728
+ code;
1729
+ details;
1730
+ constructor(message, code, details = {}) {
1731
+ super(message);
1732
+ this.name = 'SurgicalReplaceError';
1733
+ this.code = code;
1734
+ this.details = details;
1735
+ }
1736
+ }
1737
+ /**
1738
+ * Thrown by the `glob_files` tool for any non-recoverable traversal
1739
+ * failure (workspace escape, invalid pattern, etc.).
1740
+ */
1741
+ export class GlobFilesError extends Error {
1742
+ code;
1743
+ details;
1744
+ constructor(message, code, details = {}) {
1745
+ super(message);
1746
+ this.name = 'GlobFilesError';
1747
+ this.code = code;
1748
+ this.details = details;
1749
+ }
1750
+ }
1751
+ export class TodoWriteError extends Error {
1752
+ code;
1753
+ details;
1754
+ constructor(message, code, details = {}) {
1755
+ super(message);
1756
+ this.name = 'TodoWriteError';
1757
+ this.code = code;
1758
+ this.details = details;
1759
+ }
1760
+ }
1761
+ /* ──────────────────── todo_read implementation ──────────────────── */
1762
+ /**
1763
+ * Read the current todo list from `<cwd>/.fixo/todo_list.json`.
1764
+ * Always succeeds — a missing or unreadable file returns an
1765
+ * empty list (the loader is fault-tolerant by design).
1766
+ */
1767
+ export function executeTodoRead(cwd) {
1768
+ const list = loadTodoList(cwd);
1769
+ return renderTodoList(list);
1770
+ }
1771
+ /* ──────────────────── todo_write implementation ──────────────────── */
1772
+ const VALID_TODO_STATUSES = new Set([
1773
+ 'pending',
1774
+ 'in_progress',
1775
+ 'done',
1776
+ 'cancelled',
1777
+ ]);
1778
+ /**
1779
+ * Apply a todo mutation and persist the result. Rejected in
1780
+ * `PLAN` mode (Pillar 1) — todo state is project-level
1781
+ * metadata, not conversation state, so even an `add` would
1782
+ * leak a write to disk.
1783
+ */
1784
+ export async function executeTodoWrite(args, cwd, options = {}) {
1785
+ if (options.mode === 'PLAN') {
1786
+ return `Error: todo_write: rejected in PLAN mode (no on-disk mutations).`;
1787
+ }
1788
+ const op = args.op;
1789
+ if (op !== 'add' && op !== 'set_status' && op !== 'remove' && op !== 'clear_done') {
1790
+ throw new TodoWriteError(`todo_write: unknown op "${String(op)}"`, 'invalid_args', { op: String(op) });
1791
+ }
1792
+ let list = loadTodoList(cwd);
1793
+ switch (op) {
1794
+ case 'add': {
1795
+ if (typeof args.content !== 'string' || args.content.trim().length === 0) {
1796
+ throw new TodoWriteError('todo_write: "content" is required for op=add', 'invalid_args');
1797
+ }
1798
+ list = addItem(list, { content: args.content, blockedBy: args.blockedBy });
1799
+ break;
1800
+ }
1801
+ case 'set_status': {
1802
+ if (typeof args.id !== 'string' || args.id.length === 0) {
1803
+ throw new TodoWriteError('todo_write: "id" is required for op=set_status', 'invalid_args');
1804
+ }
1805
+ if (!args.status || !VALID_TODO_STATUSES.has(args.status)) {
1806
+ throw new TodoWriteError(`todo_write: "status" must be one of pending|in_progress|done|cancelled (got "${String(args.status)}")`, 'invalid_args');
1807
+ }
1808
+ const exists = list.items.some((it) => it.id === args.id);
1809
+ if (!exists) {
1810
+ throw new TodoWriteError(`todo_write: item id "${args.id}" not found`, 'not_found');
1811
+ }
1812
+ list = setItemStatus(list, { id: args.id, status: args.status });
1813
+ break;
1814
+ }
1815
+ case 'remove': {
1816
+ if (typeof args.id !== 'string' || args.id.length === 0) {
1817
+ throw new TodoWriteError('todo_write: "id" is required for op=remove', 'invalid_args');
1818
+ }
1819
+ const exists = list.items.some((it) => it.id === args.id);
1820
+ if (!exists) {
1821
+ throw new TodoWriteError(`todo_write: item id "${args.id}" not found`, 'not_found');
1822
+ }
1823
+ list = removeItem(list, { id: args.id });
1824
+ break;
1825
+ }
1826
+ case 'clear_done': {
1827
+ list = clearDoneItems(list);
1828
+ break;
1829
+ }
1830
+ }
1831
+ const save = saveTodoList(cwd, list);
1832
+ if (!save.ok) {
1833
+ throw new TodoWriteError(`todo_write: failed to persist: ${save.error ?? 'unknown'}`, 'io_failure', {
1834
+ path: save.path,
1835
+ });
1836
+ }
1837
+ const summary = summariseTodoList(list);
1838
+ recordTelemetry(telemetry.todoMutation({
1839
+ op,
1840
+ items: list.items.length,
1841
+ id: typeof args.id === 'string' ? args.id : undefined,
1842
+ }));
1843
+ // Echo the rendered list back so the LLM can confirm
1844
+ // its mutation took effect in the same turn.
1845
+ return `${renderTodoList(list)}\n\n(summary: ${summary})`;
1846
+ }
1847
+ /* ──────────────────── run_command_async / poll_command_status / kill_command ────────── */
1848
+ /**
1849
+ * Process-global registry of background jobs. Lazily created on
1850
+ * the first call to `getBackgroundJobRegistry` so the tool
1851
+ * dispatch is hot-path-friendly and test-friendly (tests inject
1852
+ * a custom registry via {@link setBackgroundJobRegistry}).
1853
+ */
1854
+ let globalBackgroundRegistry = null;
1855
+ const backgroundRegistries = new Map();
1856
+ export function getBackgroundJobRegistry(cwd) {
1857
+ let reg = backgroundRegistries.get(cwd);
1858
+ if (!reg) {
1859
+ reg = new BackgroundJobRegistry(cwd);
1860
+ backgroundRegistries.set(cwd, reg);
1861
+ }
1862
+ return reg;
1863
+ }
1864
+ /** Test-only: inject a custom registry. */
1865
+ export function setBackgroundJobRegistry(cwd, reg) {
1866
+ if (reg === null) {
1867
+ const existing = backgroundRegistries.get(cwd);
1868
+ if (existing)
1869
+ existing.shutdown();
1870
+ backgroundRegistries.delete(cwd);
1871
+ return;
1872
+ }
1873
+ const previous = backgroundRegistries.get(cwd);
1874
+ if (previous)
1875
+ previous.shutdown();
1876
+ backgroundRegistries.set(cwd, reg);
1877
+ }
1878
+ export class BackgroundCommandError extends Error {
1879
+ code;
1880
+ constructor(message, code) {
1881
+ super(message);
1882
+ this.name = 'BackgroundCommandError';
1883
+ this.code = code;
1884
+ }
1885
+ }
1886
+ /**
1887
+ * Spawn a long-running command and return its jobId without
1888
+ * blocking. The tail buffers cap at 64 KiB per stream and the
1889
+ * snapshot is fsynced to `<cwd>/.fixo/jobs/<jobId>.json` every
1890
+ * 5 s so a crash does not lose the recent output.
1891
+ */
1892
+ export async function executeRunCommandAsync(args, cwd, options = {}) {
1893
+ if (options.mode === 'PLAN') {
1894
+ return `Error: run_command_async: rejected in PLAN mode.`;
1895
+ }
1896
+ if (typeof args.cmd !== 'string' || args.cmd.trim().length === 0) {
1897
+ throw new BackgroundCommandError('run_command_async: "cmd" is required', 'invalid_args');
1898
+ }
1899
+ const cmdArgs = Array.isArray(args.args) ? args.args.filter((a) => typeof a === 'string') : [];
1900
+ const reg = getBackgroundJobRegistry(cwd);
1901
+ const result = await reg.register({
1902
+ cmd: args.cmd,
1903
+ args: cmdArgs,
1904
+ cwd: args.cwd ?? cwd,
1905
+ });
1906
+ if (!result.ok) {
1907
+ throw new BackgroundCommandError(`run_command_async: ${result.error ?? 'unknown'}`, 'spawn_failed');
1908
+ }
1909
+ return JSON.stringify({
1910
+ ok: true,
1911
+ jobId: result.jobId,
1912
+ pid: result.pid,
1913
+ note: 'poll_command_status to retrieve output; kill_command to terminate',
1914
+ });
1915
+ }
1916
+ export function executePollCommandStatus(args, cwd) {
1917
+ if (typeof args.jobId !== 'string' || args.jobId.length === 0) {
1918
+ throw new BackgroundCommandError('poll_command_status: "jobId" is required', 'invalid_args');
1919
+ }
1920
+ const reg = getBackgroundJobRegistry(cwd);
1921
+ const snap = reg.poll({
1922
+ jobId: args.jobId,
1923
+ tailLines: args.tailLines,
1924
+ sinceBytes: args.sinceBytes,
1925
+ });
1926
+ if (!snap) {
1927
+ throw new BackgroundCommandError(`poll_command_status: no such job "${args.jobId}"`, 'no_such_job');
1928
+ }
1929
+ return JSON.stringify(snap, null, 2);
1930
+ }
1931
+ export function executeKillCommand(args, cwd) {
1932
+ if (typeof args.jobId !== 'string' || args.jobId.length === 0) {
1933
+ throw new BackgroundCommandError('kill_command: "jobId" is required', 'invalid_args');
1934
+ }
1935
+ const reg = getBackgroundJobRegistry(cwd);
1936
+ const out = reg.kill(args.jobId);
1937
+ if (!out.ok) {
1938
+ throw new BackgroundCommandError(`kill_command: ${out.error ?? 'unknown'}`, 'no_such_job');
1939
+ }
1940
+ return JSON.stringify({ ok: true, jobId: args.jobId });
1941
+ }
1942
+ /** Helper exported for tests + subagent use. */
1943
+ export function shutdownAllBackgroundRegistries() {
1944
+ for (const reg of backgroundRegistries.values())
1945
+ reg.shutdown();
1946
+ backgroundRegistries.clear();
1947
+ }
1948
+ /** Helper exported for `/jobs` slash command. */
1949
+ export function listAllBackgroundJobs(cwd) {
1950
+ const reg = backgroundRegistries.get(cwd);
1951
+ if (!reg)
1952
+ return [];
1953
+ return reg.list().map((j) => ({
1954
+ id: j.id,
1955
+ status: j.status,
1956
+ exitCode: j.exitCode,
1957
+ startedAt: j.startedAt,
1958
+ exitedAt: j.exitedAt,
1959
+ cmd: j.cmd,
1960
+ args: j.args,
1961
+ cwd: j.cwd,
1962
+ stdout: j.stdoutTruncated ? `${j.stdout}\n...[truncated]` : j.stdout,
1963
+ stderr: j.stderrTruncated ? `${j.stderr}\n...[truncated]` : j.stderr,
1964
+ totalStdoutBytes: j.totalStdoutBytes,
1965
+ totalStderrBytes: j.totalStderrBytes,
1966
+ stdoutTruncated: j.stdoutTruncated,
1967
+ stderrTruncated: j.stderrTruncated,
1968
+ }));
1969
+ }
1970
+ /* ──────────────────── str_replace implementation ──────────────────── */
1971
+ /**
1972
+ * Count non-overlapping occurrences of `needle` inside `haystack`.
1973
+ * Linear scan with a moving cursor — no regex, no allocation.
1974
+ */
1975
+ function countOccurrences(haystack, needle) {
1976
+ if (needle.length === 0)
1977
+ return 0;
1978
+ let count = 0;
1979
+ let from = 0;
1980
+ while (true) {
1981
+ const idx = haystack.indexOf(needle, from);
1982
+ if (idx === -1)
1983
+ return count;
1984
+ count += 1;
1985
+ from = idx + needle.length;
1986
+ }
1987
+ }
1988
+ /**
1989
+ * Apply a single in-place string replacement. Either replaces the
1990
+ * first match (default) or every match (when `replaceAll` is true).
1991
+ */
1992
+ function applyReplacement(content, oldString, newString, replaceAll) {
1993
+ if (replaceAll) {
1994
+ // Manual split/join is faster than String.prototype.replaceAll
1995
+ // for large strings under V8 (avoids the internal RegExp).
1996
+ return content.split(oldString).join(newString);
1997
+ }
1998
+ return content.replace(oldString, newString);
1999
+ }
2000
+ /**
2001
+ * Implementation of the `str_replace` tool. Strictly typed and
2002
+ * gated by all four safety pillars:
2003
+ *
2004
+ * - Pillar 1 (Sandbox): `ctx.mode === 'PLAN'` is rejected.
2005
+ * - Pillar 5 (Self-Protection): platform-locked paths are
2006
+ * refused by `WorkspaceGuard.assertNotPlatformPath`.
2007
+ * - Pillar 2 (Atomic Staging): the final commit is funneled
2008
+ * through {@link AtomicStagingManager.applySurgicalReplace},
2009
+ * not a direct `fs.writeFileSync`.
2010
+ * - Pillar 3 (LSP pre-save): the staged content is checked by
2011
+ * the gate before commit. In `block` mode a syntax error
2012
+ * causes the edit to abort with the diagnostic reported back
2013
+ * to the model.
2014
+ */
2015
+ export async function executeStrReplace(args, cwd, options = {}) {
2016
+ const guard = new WorkspaceGuard(cwd);
2017
+ // Validate the input.
2018
+ if (typeof args.path !== 'string' || args.path.length === 0) {
2019
+ return `Error: str_replace: "path" is required.`;
2020
+ }
2021
+ if (typeof args.oldString !== 'string') {
2022
+ return `Error: str_replace: "oldString" is required.`;
2023
+ }
2024
+ if (typeof args.newString !== 'string') {
2025
+ return `Error: str_replace: "newString" is required.`;
2026
+ }
2027
+ // Pillar 1 — refuse in PLAN mode.
2028
+ if (options.mode === 'PLAN') {
2029
+ const err = new SurgicalReplaceError(`str_replace is not allowed in PLAN mode (read-only). Switch to BUILD mode and retry.`, 'plan_mode_rejected', { mode: options.mode });
2030
+ return `Error: ${err.message}`;
2031
+ }
2032
+ // Workspace boundary check.
2033
+ let resolved;
2034
+ try {
2035
+ resolved = guard.resolve(args.path, 'str_replace target');
2036
+ }
2037
+ catch (err) {
2038
+ const msg = err instanceof Error ? err.message : String(err);
2039
+ return `Error: str_replace: ${msg}`;
2040
+ }
2041
+ // Pillar 5 — refuse platform-locked paths.
2042
+ try {
2043
+ guard.assertNotPlatformPath(resolved);
2044
+ }
2045
+ catch (err) {
2046
+ const msg = err instanceof Error ? err.message : String(err);
2047
+ return `Error: ${msg}`;
2048
+ }
2049
+ if (!fs.existsSync(resolved)) {
2050
+ return `Error: str_replace: file not found: ${args.path}`;
2051
+ }
2052
+ if (guard.isBinaryFile(resolved)) {
2053
+ return `Error: str_replace: file appears to be binary: ${args.path}`;
2054
+ }
2055
+ // Load the current content.
2056
+ const content = fs.readFileSync(resolved, 'utf-8');
2057
+ const occurrences = countOccurrences(content, args.oldString);
2058
+ const replaceAll = args.replaceAll === true;
2059
+ // The uniqueness gate only fires when the caller has not already
2060
+ // opted in to non-unique semantics via `replaceAll` or by
2061
+ // explicitly setting `expectUnique: false`.
2062
+ const expectUnique = !replaceAll && args.expectUnique !== false;
2063
+ if (occurrences === 0) {
2064
+ return `Error: str_replace: oldString not found in ${args.path}.`;
2065
+ }
2066
+ if (occurrences > 1 && expectUnique) {
2067
+ return `Error: str_replace: oldString appears ${occurrences} times in ${args.path}. ` +
2068
+ `Pass replaceAll=true or expectUnique=false to proceed.`;
2069
+ }
2070
+ const newContent = applyReplacement(content, args.oldString, args.newString, replaceAll);
2071
+ const bytes = Buffer.byteLength(newContent, 'utf-8');
2072
+ // Pillar 3 — LSP pre-save compilation gate. The gate's
2073
+ // `enforce` throws on `block`-mode failures; we surface the
2074
+ // diagnostics to the LLM as a structured error.
2075
+ if (options.safety?.lspPreSave && options.safety.lspPreSave !== 'off') {
2076
+ try {
2077
+ const gate = getOrCreateLspGate(cwd, options.safety);
2078
+ const synthetic = {
2079
+ id: 'surgical-' + Date.now().toString(36),
2080
+ targetPath: resolved,
2081
+ pendingPath: '<surgical>',
2082
+ metaPath: '<surgical>',
2083
+ createdAt: Date.now(),
2084
+ mode: 0o644,
2085
+ };
2086
+ const result = await gate.check(synthetic);
2087
+ gate.enforce(result, synthetic);
2088
+ }
2089
+ catch (err) {
2090
+ const msg = err instanceof Error ? err.message : String(err);
2091
+ return `Error: str_replace: ${msg}`;
2092
+ }
2093
+ }
2094
+ // Pillar 2 — atomic staging. Route the new content through the
2095
+ // staging manager's new surgical-replace method.
2096
+ try {
2097
+ const mgr = new AtomicStagingManager(cwd, getOrCreateRunId());
2098
+ const result = await mgr.applySurgicalReplace(resolved, newContent, {
2099
+ runId: mgr.runId,
2100
+ reason: 'str_replace',
2101
+ actorId: options.session?.id ?? 'tool-executor',
2102
+ });
2103
+ // Telemetry.
2104
+ try {
2105
+ const { recordTelemetry, telemetry } = await import('./telemetry.js');
2106
+ recordTelemetry(telemetry.surgicalEdit({
2107
+ path: args.path,
2108
+ occurrences: replaceAll ? occurrences : 1,
2109
+ mode: options.safety?.lspPreSave ?? 'off',
2110
+ bytes: result.bytes,
2111
+ }));
2112
+ }
2113
+ catch {
2114
+ // Telemetry must never break a tool call.
2115
+ }
2116
+ options.session?.noteChange(resolved);
2117
+ return JSON.stringify({
2118
+ ok: true,
2119
+ path: args.path,
2120
+ occurrences: replaceAll ? occurrences : 1,
2121
+ mode: options.safety?.lspPreSave ?? 'off',
2122
+ bytes: result.bytes,
2123
+ });
2124
+ }
2125
+ catch (err) {
2126
+ const msg = err instanceof Error ? err.message : String(err);
2127
+ return `Error: str_replace: ${msg}`;
2128
+ }
2129
+ }
2130
+ /* ──────────────────── glob_files implementation ──────────────────── */
2131
+ const GLOB_DEFAULT_MAX_RESULTS = 1000;
2132
+ const GLOB_HARD_MAX_RESULTS = 5000;
2133
+ const GLOB_DEFAULT_SKIP_DIRS = [
2134
+ 'node_modules',
2135
+ '.git',
2136
+ 'dist',
2137
+ '.fixo',
2138
+ '.fixocli',
2139
+ ];
2140
+ /**
2141
+ * Split a comma-separated ignore spec into an array. Tolerates
2142
+ * stray whitespace and empty entries.
2143
+ */
2144
+ function splitIgnoreSpec(spec) {
2145
+ if (!spec)
2146
+ return [];
2147
+ return spec
2148
+ .split(',')
2149
+ .map((s) => s.trim())
2150
+ .filter((s) => s.length > 0);
2151
+ }
2152
+ /**
2153
+ * Implementation of the `glob_files` tool. Strictly typed,
2154
+ * workspace-bounded, and capped.
2155
+ *
2156
+ * Uses Node 22+ native `fs.promises.glob` for performance. Falls
2157
+ * back to a manual recursive walker on Node < 22 (which the
2158
+ * project's `package.json` already requires) so the tool is
2159
+ * always functional.
2160
+ */
2161
+ export async function executeGlobFiles(args, cwd, _options = {}) {
2162
+ if (typeof args.pattern !== 'string' || args.pattern.length === 0) {
2163
+ return `Error: glob_files: "pattern" is required.`;
2164
+ }
2165
+ const guard = new WorkspaceGuard(cwd);
2166
+ let scope;
2167
+ try {
2168
+ scope = args.cwd ? guard.resolve(args.cwd, 'glob scope') : cwd;
2169
+ }
2170
+ catch (err) {
2171
+ const msg = err instanceof Error ? err.message : String(err);
2172
+ return `Error: glob_files: ${msg}`;
2173
+ }
2174
+ const maxResults = Math.min(Math.max(args.maxResults ?? GLOB_DEFAULT_MAX_RESULTS, 1), GLOB_HARD_MAX_RESULTS);
2175
+ const skipDirs = new Set(GLOB_DEFAULT_SKIP_DIRS);
2176
+ for (const extra of splitIgnoreSpec(args.ignore)) {
2177
+ skipDirs.add(extra);
2178
+ }
2179
+ const includeHidden = args.includeHidden === true;
2180
+ const followSymlinks = args.followSymlinks === true;
2181
+ // Collect matching paths.
2182
+ let matches;
2183
+ try {
2184
+ matches = await collectGlobMatches({
2185
+ pattern: args.pattern,
2186
+ scope,
2187
+ skipDirs,
2188
+ includeHidden,
2189
+ followSymlinks,
2190
+ maxResults: maxResults + 1,
2191
+ });
2192
+ }
2193
+ catch (err) {
2194
+ const msg = err instanceof Error ? err.message : String(err);
2195
+ return `Error: glob_files: ${msg}`;
2196
+ }
2197
+ const truncated = matches.length > maxResults;
2198
+ const returned = truncated ? matches.slice(0, maxResults) : matches;
2199
+ const total = matches.length;
2200
+ // Telemetry.
2201
+ try {
2202
+ const { recordTelemetry, telemetry } = await import('./telemetry.js');
2203
+ recordTelemetry(telemetry.glob({
2204
+ pattern: args.pattern,
2205
+ returned: returned.length,
2206
+ truncated,
2207
+ }));
2208
+ }
2209
+ catch {
2210
+ // Telemetry must never break a tool call.
2211
+ }
2212
+ if (returned.length === 0) {
2213
+ return JSON.stringify({ pattern: args.pattern, matches: [], total: 0, truncated: false });
2214
+ }
2215
+ return JSON.stringify({
2216
+ pattern: args.pattern,
2217
+ matches: returned.map((p) => path.relative(cwd, p) || p),
2218
+ total,
2219
+ truncated,
2220
+ });
2221
+ }
2222
+ /**
2223
+ * Collect paths matching `pattern` under `scope`. Prefers Node 22+
2224
+ * native `fs.promises.glob`; falls back to a manual recursive
2225
+ * walker on older runtimes. Both branches honour the skip set
2226
+ * and hidden-file policy.
2227
+ */
2228
+ async function collectGlobMatches(opts) {
2229
+ const { pattern, scope, skipDirs, includeHidden, followSymlinks, maxResults } = opts;
2230
+ const results = [];
2231
+ // Try native fs.promises.glob first.
2232
+ const fsPromises = fs.promises;
2233
+ if (typeof fsPromises.glob === 'function') {
2234
+ const exclude = Array.from(skipDirs).map((d) => `**/${d}/**`);
2235
+ const out = (await fsPromises.glob(pattern, {
2236
+ cwd: scope,
2237
+ exclude,
2238
+ withFileTypes: false,
2239
+ }));
2240
+ if (Array.isArray(out)) {
2241
+ for (const entry of out) {
2242
+ if (typeof entry !== 'string')
2243
+ continue;
2244
+ const absolute = path.resolve(scope, entry);
2245
+ if (results.length >= maxResults)
2246
+ break;
2247
+ // The native glob does not surface whether symlinks were
2248
+ // followed; perform a defensive lstat and skip dotfiles if
2249
+ // the user did not opt in.
2250
+ try {
2251
+ if (!includeHidden && path.basename(absolute).startsWith('.'))
2252
+ continue;
2253
+ }
2254
+ catch {
2255
+ continue;
2256
+ }
2257
+ results.push(absolute);
2258
+ }
2259
+ return results;
2260
+ }
2261
+ }
2262
+ // Manual fallback — recursive walker with built-in pattern
2263
+ // matcher. Supports `**`, `*`, and literal segments.
2264
+ const matcher = compileGlob(pattern);
2265
+ const walk = async (dir) => {
2266
+ if (results.length >= maxResults)
2267
+ return;
2268
+ let entries;
2269
+ try {
2270
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
2271
+ }
2272
+ catch {
2273
+ return;
2274
+ }
2275
+ for (const entry of entries) {
2276
+ if (results.length >= maxResults)
2277
+ return;
2278
+ if (!includeHidden && entry.name.startsWith('.'))
2279
+ continue;
2280
+ const full = path.join(dir, entry.name);
2281
+ if (entry.isDirectory()) {
2282
+ if (skipDirs.has(entry.name))
2283
+ continue;
2284
+ if (!followSymlinks) {
2285
+ try {
2286
+ const lst = await fs.promises.lstat(full);
2287
+ if (lst.isSymbolicLink())
2288
+ continue;
2289
+ }
2290
+ catch {
2291
+ continue;
2292
+ }
2293
+ }
2294
+ await walk(full);
2295
+ }
2296
+ else if (entry.isFile()) {
2297
+ if (!includeHidden && entry.name.startsWith('.'))
2298
+ continue;
2299
+ if (skipDirs.has(entry.name))
2300
+ continue;
2301
+ if (!followSymlinks) {
2302
+ try {
2303
+ const lst = await fs.promises.lstat(full);
2304
+ if (lst.isSymbolicLink())
2305
+ continue;
2306
+ }
2307
+ catch {
2308
+ continue;
2309
+ }
2310
+ }
2311
+ if (matcher(path.relative(scope, full) || entry.name)) {
2312
+ results.push(full);
2313
+ }
2314
+ }
2315
+ }
2316
+ };
2317
+ await walk(scope);
2318
+ return results;
2319
+ }
2320
+ /**
2321
+ * Compile a small subset of glob syntax into a predicate. Supports:
2322
+ * - `**` → any number of path segments
2323
+ * - `*` → any characters within a single segment
2324
+ * - `?` → any single character
2325
+ * - everything else is a literal
2326
+ *
2327
+ * The matcher operates on forward-slash paths regardless of host
2328
+ * platform. This is intentionally tiny — Node 22+'s native
2329
+ * `fs.promises.glob` covers the common case; the fallback exists
2330
+ * for environments where the native API is unavailable.
2331
+ */
2332
+ function compileGlob(pattern) {
2333
+ // Normalise separators.
2334
+ const normalised = pattern.replace(/\\/g, '/');
2335
+ const re = globToRegExp(normalised);
2336
+ return (rel) => re.test(rel.replace(/\\/g, '/'));
2337
+ }
2338
+ /**
2339
+ * Convert a glob pattern to a regular expression. Faithful to the
2340
+ * small subset documented on {@link compileGlob}.
2341
+ */
2342
+ function globToRegExp(pattern) {
2343
+ let body = '';
2344
+ let i = 0;
2345
+ while (i < pattern.length) {
2346
+ const ch = pattern[i];
2347
+ if (ch === '*') {
2348
+ if (pattern[i + 1] === '*') {
2349
+ body += '.*';
2350
+ i += 2;
2351
+ // Consume an optional following slash so `**/foo` works.
2352
+ if (pattern[i] === '/')
2353
+ i += 1;
2354
+ }
2355
+ else {
2356
+ body += '[^/]*';
2357
+ i += 1;
2358
+ }
2359
+ }
2360
+ else if (ch === '?') {
2361
+ body += '[^/]';
2362
+ i += 1;
2363
+ }
2364
+ else if ('\\^$.|+()[]{}'.includes(ch)) {
2365
+ body += '\\' + ch;
2366
+ i += 1;
2367
+ }
2368
+ else {
2369
+ body += ch;
2370
+ i += 1;
2371
+ }
2372
+ }
2373
+ return new RegExp('^' + body + '$');
2374
+ }
2375
+ /* ──────────────────── Tool Specification Registry ──────────────────── */
2376
+ /**
2377
+ * Process-global registry of typed tool specifications. New tools
2378
+ * register themselves here; the existing `TOOL_DEFINITIONS` array
2379
+ * remains the LLM-facing JSON-Schema surface. Both surfaces are
2380
+ * kept in lock-step by the executor's dispatch path.
2381
+ *
2382
+ * The registry stores entries as `ToolSpecification<Record<string, any>, string>`
2383
+ * for compatibility with the existing switch dispatch. Strongly-typed
2384
+ * callers can cast at the call site.
2385
+ */
2386
+ const toolRegistry = new Map();
2387
+ /** Register a typed tool specification. Throws on duplicate. */
2388
+ export function registerTool(spec) {
2389
+ if (toolRegistry.has(spec.name)) {
2390
+ throw new Error(`Tool "${spec.name}" is already registered.`);
2391
+ }
2392
+ toolRegistry.set(spec.name, spec);
2393
+ }
2394
+ /** Look up a registered tool specification. */
2395
+ export function getToolSpecification(name) {
2396
+ return toolRegistry.get(name);
2397
+ }
2398
+ /** List the names of all registered typed tools. */
2399
+ export function listRegisteredToolNames() {
2400
+ return Array.from(toolRegistry.keys()).sort();
2401
+ }
2402
+ /* ──────────────────── Built-in Typed Registrations ──────────────────── */
2403
+ registerTool({
2404
+ name: 'str_replace',
2405
+ description: 'Use this tool by default for any in-place edit to an existing file. Performs a surgical, atomic replacement of oldString with newString. By default, oldString must be unique within the file (expectUnique=true) — non-unique matches are rejected with a clear error so the caller can narrow the snippet. Pass replaceAll=true to substitute every occurrence. Rejected in PLAN mode. Refused for platform-locked paths. Goes through the same atomic staging pipeline as write_file and the LSP pre-save gate before any disk mutation.',
2406
+ parameters: {
2407
+ type: 'object',
2408
+ properties: {
2409
+ path: { type: 'string', description: 'The file path to edit.' },
2410
+ oldString: { type: 'string', description: 'The substring to replace.' },
2411
+ newString: { type: 'string', description: 'The replacement content.' },
2412
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence.' },
2413
+ expectUnique: {
2414
+ type: 'boolean',
2415
+ description: 'Abort when oldString is not unique.',
2416
+ },
2417
+ },
2418
+ required: ['path', 'oldString', 'newString'],
2419
+ },
2420
+ async execute(args, ctx) {
2421
+ return executeStrReplace(args, ctx.cwd, ctx.options);
2422
+ },
2423
+ });
2424
+ registerTool({
2425
+ name: 'glob_files',
2426
+ description: 'High-performance filesystem pattern matcher. Returns paths matching the given glob pattern, relative to the workspace root. Built on Node 22+ native fs.promises.glob. By default, common build/VCS directories (node_modules, .git, dist, .fixo, .fixocli) are excluded. Symlinks are not followed and hidden files are excluded unless explicitly enabled. Capped at maxResults (default 1000, hard cap 5000).',
2427
+ parameters: {
2428
+ type: 'object',
2429
+ properties: {
2430
+ pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.ts"' },
2431
+ cwd: { type: 'string', description: 'Optional scope directory.' },
2432
+ ignore: { type: 'string', description: 'Optional extra ignore patterns.' },
2433
+ maxResults: { type: 'integer', description: 'Cap on returned matches.' },
2434
+ includeHidden: { type: 'boolean', description: 'Include dotfile entries.' },
2435
+ followSymlinks: { type: 'boolean', description: 'Follow symbolic links.' },
2436
+ },
2437
+ required: ['pattern'],
2438
+ },
2439
+ async execute(args, ctx) {
2440
+ return executeGlobFiles(args, ctx.cwd, ctx.options);
2441
+ },
2442
+ });
2443
+ registerTool({
2444
+ name: 'todo_read',
2445
+ description: 'Read the current project todo list from <cwd>/.fixo/todo_list.json. Returns a human-readable rendering of all items, grouped into Open and Completed. Missing or unreadable files yield an empty list — this is by design so the agent can always inspect todo state.',
2446
+ parameters: {
2447
+ type: 'object',
2448
+ properties: {},
2449
+ required: [],
2450
+ },
2451
+ async execute(_args, ctx) {
2452
+ return executeTodoRead(ctx.cwd);
2453
+ },
2454
+ });
2455
+ registerTool({
2456
+ name: 'todo_write',
2457
+ description: 'Mutate the project todo list. Operations: add (content+blockedBy optional), set_status (id+status), remove (id), clear_done. Persisted atomically to <cwd>/.fixo/todo_list.json. Rejected in PLAN mode. Status values: pending | in_progress | done | cancelled.',
2458
+ parameters: {
2459
+ type: 'object',
2460
+ properties: {
2461
+ op: { type: 'string', enum: ['add', 'set_status', 'remove', 'clear_done'] },
2462
+ content: { type: 'string', description: 'Item content (op=add only).' },
2463
+ id: { type: 'string', description: 'Item id (op=set_status, op=remove).' },
2464
+ status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'cancelled'] },
2465
+ blockedBy: { type: 'string', description: 'Optional blocker (op=add).' },
2466
+ },
2467
+ required: ['op'],
2468
+ },
2469
+ async execute(args, ctx) {
2470
+ return executeTodoWrite(args, ctx.cwd, ctx.options);
2471
+ },
2472
+ });
2473
+ registerTool({
2474
+ name: 'run_command_async',
2475
+ description: 'Spawn a long-running command in the background. Returns a jobId. Streams cap at 64 KiB; tail buffers survive a crash via the .fixo/jobs/<jobId>.json snapshot. Rejected in PLAN mode. Workspace-escape and sensitive-file commands are blocked at spawn time.',
2476
+ parameters: {
2477
+ type: 'object',
2478
+ properties: {
2479
+ cmd: { type: 'string', description: 'The binary to spawn.' },
2480
+ args: { type: 'array', items: { type: 'string' } },
2481
+ cwd: { type: 'string' },
2482
+ },
2483
+ required: ['cmd'],
2484
+ },
2485
+ async execute(args, ctx) {
2486
+ return executeRunCommandAsync(args, ctx.cwd, ctx.options);
2487
+ },
2488
+ });
2489
+ registerTool({
2490
+ name: 'poll_command_status',
2491
+ description: 'Read a snapshot of a background job. Honours tailLines (last N lines per stream) and sinceBytes (return only the bytes after this offset on stdout/stderr).',
2492
+ parameters: {
2493
+ type: 'object',
2494
+ properties: {
2495
+ jobId: { type: 'string' },
2496
+ tailLines: { type: 'integer' },
2497
+ sinceBytes: { type: 'integer' },
2498
+ },
2499
+ required: ['jobId'],
2500
+ },
2501
+ async execute(args, ctx) {
2502
+ return executePollCommandStatus(args, ctx.cwd);
2503
+ },
2504
+ });
2505
+ registerTool({
2506
+ name: 'kill_command',
2507
+ description: 'SIGTERM a background job by jobId.',
2508
+ parameters: {
2509
+ type: 'object',
2510
+ properties: {
2511
+ jobId: { type: 'string' },
2512
+ },
2513
+ required: ['jobId'],
2514
+ },
2515
+ async execute(args, ctx) {
2516
+ return executeKillCommand(args, ctx.cwd);
2517
+ },
2518
+ });
2519
+ //# sourceMappingURL=tool-executor.js.map