@build-astron-co/nimbus 0.2.0 → 0.4.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 (469) hide show
  1. package/bin/nimbus +26 -10
  2. package/bin/nimbus.cmd +41 -0
  3. package/bin/nimbus.mjs +70 -0
  4. package/completions/nimbus.bash +38 -0
  5. package/completions/nimbus.fish +48 -0
  6. package/completions/nimbus.zsh +81 -0
  7. package/dist/src/agent/compaction-agent.js +215 -0
  8. package/dist/src/agent/context-manager.js +385 -0
  9. package/dist/src/agent/context.js +322 -0
  10. package/dist/src/agent/deploy-preview.js +395 -0
  11. package/dist/src/agent/expand-files.js +95 -0
  12. package/dist/src/agent/index.js +18 -0
  13. package/dist/src/agent/loop.js +1535 -0
  14. package/dist/src/agent/modes.js +347 -0
  15. package/dist/src/agent/permissions.js +396 -0
  16. package/dist/src/agent/subagents/base.js +67 -0
  17. package/dist/src/agent/subagents/cost.js +45 -0
  18. package/dist/src/agent/subagents/explore.js +36 -0
  19. package/dist/src/agent/subagents/general.js +41 -0
  20. package/dist/src/agent/subagents/index.js +88 -0
  21. package/dist/src/agent/subagents/infra.js +52 -0
  22. package/dist/src/agent/subagents/security.js +60 -0
  23. package/dist/src/agent/system-prompt.js +860 -0
  24. package/dist/src/app.js +152 -0
  25. package/dist/src/audit/activity-log.js +209 -0
  26. package/dist/src/audit/compliance-checker.js +419 -0
  27. package/dist/src/audit/cost-tracker.js +231 -0
  28. package/dist/src/audit/index.js +10 -0
  29. package/dist/src/audit/security-scanner.js +490 -0
  30. package/dist/src/auth/guard.js +64 -0
  31. package/dist/src/auth/index.js +19 -0
  32. package/dist/src/auth/keychain.js +79 -0
  33. package/dist/src/auth/oauth.js +389 -0
  34. package/dist/src/auth/providers.js +415 -0
  35. package/dist/src/auth/sso.js +87 -0
  36. package/dist/src/auth/store.js +424 -0
  37. package/dist/src/auth/types.js +5 -0
  38. package/dist/src/cli/index.js +8 -0
  39. package/dist/src/cli/init.js +1048 -0
  40. package/dist/src/cli/openapi-spec.js +346 -0
  41. package/dist/src/cli/run.js +505 -0
  42. package/dist/src/cli/serve-auth.js +56 -0
  43. package/dist/src/cli/serve.js +432 -0
  44. package/dist/src/cli/web.js +50 -0
  45. package/dist/src/cli.js +1574 -0
  46. package/dist/src/clients/core-engine-client.js +156 -0
  47. package/dist/src/clients/enterprise-client.js +246 -0
  48. package/dist/src/clients/generator-client.js +219 -0
  49. package/dist/src/clients/git-client.js +367 -0
  50. package/dist/src/clients/github-client.js +229 -0
  51. package/dist/src/clients/helm-client.js +299 -0
  52. package/dist/src/clients/index.js +18 -0
  53. package/dist/src/clients/k8s-client.js +270 -0
  54. package/dist/src/clients/llm-client.js +119 -0
  55. package/dist/src/clients/rest-client.js +104 -0
  56. package/dist/src/clients/service-discovery.js +35 -0
  57. package/dist/src/clients/terraform-client.js +302 -0
  58. package/dist/src/clients/tools-client.js +1227 -0
  59. package/dist/src/clients/ws-client.js +93 -0
  60. package/dist/src/commands/alias.js +91 -0
  61. package/dist/src/commands/analyze/index.js +313 -0
  62. package/dist/src/commands/apply/helm.js +375 -0
  63. package/dist/src/commands/apply/index.js +176 -0
  64. package/dist/src/commands/apply/k8s.js +350 -0
  65. package/dist/src/commands/apply/terraform.js +465 -0
  66. package/dist/src/commands/ask.js +137 -0
  67. package/dist/src/commands/audit/index.js +322 -0
  68. package/dist/src/commands/auth-cloud.js +345 -0
  69. package/dist/src/commands/auth-list.js +112 -0
  70. package/dist/src/commands/auth-profile.js +104 -0
  71. package/dist/src/commands/auth-refresh.js +161 -0
  72. package/dist/src/commands/auth-status.js +122 -0
  73. package/dist/src/commands/aws/ec2.js +402 -0
  74. package/dist/src/commands/aws/iam.js +304 -0
  75. package/dist/src/commands/aws/index.js +108 -0
  76. package/dist/src/commands/aws/lambda.js +317 -0
  77. package/dist/src/commands/aws/rds.js +345 -0
  78. package/dist/src/commands/aws/s3.js +346 -0
  79. package/dist/src/commands/aws/vpc.js +302 -0
  80. package/dist/src/commands/aws-discover.js +413 -0
  81. package/dist/src/commands/aws-terraform.js +618 -0
  82. package/dist/src/commands/azure/aks.js +305 -0
  83. package/dist/src/commands/azure/functions.js +200 -0
  84. package/dist/src/commands/azure/index.js +93 -0
  85. package/dist/src/commands/azure/storage.js +378 -0
  86. package/dist/src/commands/azure/vm.js +291 -0
  87. package/dist/src/commands/billing/index.js +224 -0
  88. package/dist/src/commands/chat.js +259 -0
  89. package/dist/src/commands/completions.js +255 -0
  90. package/dist/src/commands/config.js +291 -0
  91. package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
  92. package/dist/src/commands/cost/estimator.js +73 -0
  93. package/dist/src/commands/cost/index.js +625 -0
  94. package/dist/src/commands/cost/parsers/terraform.js +234 -0
  95. package/dist/src/commands/cost/parsers/types.js +4 -0
  96. package/dist/src/commands/cost/pricing/aws.js +501 -0
  97. package/dist/src/commands/cost/pricing/azure.js +462 -0
  98. package/dist/src/commands/cost/pricing/gcp.js +359 -0
  99. package/dist/src/commands/cost/pricing/index.js +24 -0
  100. package/dist/src/commands/demo.js +196 -0
  101. package/dist/src/commands/deploy.js +215 -0
  102. package/dist/src/commands/doctor.js +1291 -0
  103. package/dist/src/commands/drift/index.js +674 -0
  104. package/dist/src/commands/explain.js +235 -0
  105. package/dist/src/commands/export.js +120 -0
  106. package/dist/src/commands/feedback.js +319 -0
  107. package/dist/src/commands/fix.js +263 -0
  108. package/dist/src/commands/fs/index.js +338 -0
  109. package/dist/src/commands/gcp/compute.js +266 -0
  110. package/dist/src/commands/gcp/functions.js +221 -0
  111. package/dist/src/commands/gcp/gke.js +357 -0
  112. package/dist/src/commands/gcp/iam.js +295 -0
  113. package/dist/src/commands/gcp/index.js +105 -0
  114. package/dist/src/commands/gcp/storage.js +232 -0
  115. package/dist/src/commands/generate-helm.js +1026 -0
  116. package/dist/src/commands/generate-k8s.js +1263 -0
  117. package/dist/src/commands/generate-terraform.js +1058 -0
  118. package/dist/src/commands/gh/index.js +663 -0
  119. package/dist/src/commands/git/index.js +1208 -0
  120. package/dist/src/commands/helm/index.js +985 -0
  121. package/dist/src/commands/help.js +639 -0
  122. package/dist/src/commands/history.js +120 -0
  123. package/dist/src/commands/import.js +782 -0
  124. package/dist/src/commands/incident.js +144 -0
  125. package/dist/src/commands/index.js +109 -0
  126. package/dist/src/commands/init.js +955 -0
  127. package/dist/src/commands/k8s/index.js +979 -0
  128. package/dist/src/commands/login.js +588 -0
  129. package/dist/src/commands/logout.js +61 -0
  130. package/dist/src/commands/logs.js +160 -0
  131. package/dist/src/commands/onboarding.js +382 -0
  132. package/dist/src/commands/pipeline.js +153 -0
  133. package/dist/src/commands/plan/display.js +216 -0
  134. package/dist/src/commands/plan/index.js +525 -0
  135. package/dist/src/commands/plugin.js +325 -0
  136. package/dist/src/commands/preview.js +356 -0
  137. package/dist/src/commands/profile.js +297 -0
  138. package/dist/src/commands/questionnaire.js +1021 -0
  139. package/dist/src/commands/resume.js +35 -0
  140. package/dist/src/commands/rollback.js +259 -0
  141. package/dist/src/commands/rollout.js +74 -0
  142. package/dist/src/commands/runbook.js +307 -0
  143. package/dist/src/commands/schedule.js +202 -0
  144. package/dist/src/commands/status.js +213 -0
  145. package/dist/src/commands/team/index.js +309 -0
  146. package/dist/src/commands/team-context.js +200 -0
  147. package/dist/src/commands/template.js +204 -0
  148. package/dist/src/commands/tf/index.js +989 -0
  149. package/dist/src/commands/upgrade.js +515 -0
  150. package/dist/src/commands/usage/index.js +118 -0
  151. package/dist/src/commands/version.js +145 -0
  152. package/dist/src/commands/watch.js +127 -0
  153. package/dist/src/compat/index.js +2 -0
  154. package/dist/src/compat/runtime.js +10 -0
  155. package/dist/src/compat/sqlite.js +144 -0
  156. package/dist/src/config/index.js +6 -0
  157. package/dist/src/config/manager.js +469 -0
  158. package/dist/src/config/mode-store.js +57 -0
  159. package/dist/src/config/profiles.js +66 -0
  160. package/dist/src/config/safety-policy.js +251 -0
  161. package/dist/src/config/schema.js +107 -0
  162. package/dist/src/config/types.js +311 -0
  163. package/dist/src/config/workspace-state.js +38 -0
  164. package/dist/src/context/context-db.js +138 -0
  165. package/dist/src/demo/index.js +295 -0
  166. package/dist/src/demo/scenarios/full-journey.js +226 -0
  167. package/dist/src/demo/scenarios/getting-started.js +124 -0
  168. package/dist/src/demo/scenarios/helm-release.js +334 -0
  169. package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
  170. package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
  171. package/dist/src/demo/types.js +6 -0
  172. package/dist/src/engine/cost-estimator.js +334 -0
  173. package/dist/src/engine/diagram-generator.js +192 -0
  174. package/dist/src/engine/drift-detector.js +688 -0
  175. package/dist/src/engine/executor.js +832 -0
  176. package/dist/src/engine/index.js +39 -0
  177. package/dist/src/engine/orchestrator.js +436 -0
  178. package/dist/src/engine/planner.js +616 -0
  179. package/dist/src/engine/safety.js +609 -0
  180. package/dist/src/engine/verifier.js +664 -0
  181. package/dist/src/enterprise/audit.js +241 -0
  182. package/dist/src/enterprise/auth.js +189 -0
  183. package/dist/src/enterprise/billing.js +512 -0
  184. package/dist/src/enterprise/index.js +16 -0
  185. package/dist/src/enterprise/teams.js +315 -0
  186. package/dist/src/generator/best-practices.js +1375 -0
  187. package/dist/src/generator/helm.js +495 -0
  188. package/dist/src/generator/index.js +11 -0
  189. package/dist/src/generator/intent-parser.js +420 -0
  190. package/dist/src/generator/kubernetes.js +773 -0
  191. package/dist/src/generator/terraform.js +1472 -0
  192. package/dist/src/history/index.js +6 -0
  193. package/dist/src/history/manager.js +199 -0
  194. package/dist/src/history/types.js +6 -0
  195. package/dist/src/hooks/config.js +318 -0
  196. package/dist/src/hooks/engine.js +317 -0
  197. package/dist/src/hooks/index.js +2 -0
  198. package/dist/src/llm/auth-bridge.js +157 -0
  199. package/dist/src/llm/circuit-breaker.js +116 -0
  200. package/dist/src/llm/config-loader.js +172 -0
  201. package/dist/src/llm/cost-calculator.js +137 -0
  202. package/dist/src/llm/index.js +7 -0
  203. package/dist/src/llm/model-aliases.js +99 -0
  204. package/dist/src/llm/provider-registry.js +57 -0
  205. package/dist/src/llm/providers/anthropic.js +430 -0
  206. package/dist/src/llm/providers/bedrock.js +409 -0
  207. package/dist/src/llm/providers/google.js +344 -0
  208. package/dist/src/llm/providers/ollama.js +661 -0
  209. package/dist/src/llm/providers/openai-compatible.js +289 -0
  210. package/dist/src/llm/providers/openai.js +284 -0
  211. package/dist/src/llm/providers/openrouter.js +293 -0
  212. package/dist/src/llm/router.js +844 -0
  213. package/dist/src/llm/types.js +69 -0
  214. package/dist/src/lsp/client.js +239 -0
  215. package/dist/src/lsp/languages.js +95 -0
  216. package/dist/src/lsp/manager.js +243 -0
  217. package/dist/src/mcp/client.js +289 -0
  218. package/dist/src/mcp/index.js +5 -0
  219. package/dist/src/mcp/manager.js +113 -0
  220. package/dist/src/nimbus.js +212 -0
  221. package/dist/src/plugins/index.js +13 -0
  222. package/dist/src/plugins/loader.js +280 -0
  223. package/dist/src/plugins/manager.js +282 -0
  224. package/dist/src/plugins/types.js +23 -0
  225. package/dist/src/scanners/cicd-scanner.js +230 -0
  226. package/dist/src/scanners/cloud-scanner.js +415 -0
  227. package/dist/src/scanners/framework-scanner.js +430 -0
  228. package/dist/src/scanners/iac-scanner.js +350 -0
  229. package/dist/src/scanners/index.js +454 -0
  230. package/dist/src/scanners/language-scanner.js +258 -0
  231. package/dist/src/scanners/package-manager-scanner.js +252 -0
  232. package/dist/src/scanners/types.js +6 -0
  233. package/dist/src/sessions/manager.js +395 -0
  234. package/dist/src/sessions/types.js +4 -0
  235. package/dist/src/sharing/sync.js +238 -0
  236. package/dist/src/sharing/viewer.js +131 -0
  237. package/dist/src/snapshots/index.js +1 -0
  238. package/dist/src/snapshots/manager.js +432 -0
  239. package/dist/src/state/artifacts.js +94 -0
  240. package/dist/src/state/audit.js +73 -0
  241. package/dist/src/state/billing.js +126 -0
  242. package/dist/src/state/checkpoints.js +81 -0
  243. package/dist/src/state/config.js +58 -0
  244. package/dist/src/state/conversations.js +7 -0
  245. package/dist/src/state/credentials.js +96 -0
  246. package/dist/src/state/db.js +53 -0
  247. package/dist/src/state/index.js +23 -0
  248. package/dist/src/state/messages.js +76 -0
  249. package/dist/src/state/projects.js +92 -0
  250. package/dist/src/state/schema.js +233 -0
  251. package/dist/src/state/sessions.js +79 -0
  252. package/dist/src/state/teams.js +131 -0
  253. package/dist/src/telemetry.js +91 -0
  254. package/dist/src/tools/aws-ops.js +747 -0
  255. package/dist/src/tools/azure-ops.js +491 -0
  256. package/dist/src/tools/file-ops.js +451 -0
  257. package/dist/src/tools/gcp-ops.js +559 -0
  258. package/dist/src/tools/git-ops.js +557 -0
  259. package/dist/src/tools/github-ops.js +460 -0
  260. package/dist/src/tools/helm-ops.js +634 -0
  261. package/dist/src/tools/index.js +16 -0
  262. package/dist/src/tools/k8s-ops.js +579 -0
  263. package/dist/src/tools/schemas/converter.js +129 -0
  264. package/dist/src/tools/schemas/devops.js +3319 -0
  265. package/dist/src/tools/schemas/index.js +19 -0
  266. package/dist/src/tools/schemas/standard.js +966 -0
  267. package/dist/src/tools/schemas/types.js +409 -0
  268. package/dist/src/tools/spawn-exec.js +109 -0
  269. package/dist/src/tools/terraform-ops.js +627 -0
  270. package/dist/src/types/config.js +1 -0
  271. package/dist/src/types/drift.js +4 -0
  272. package/dist/src/types/enterprise.js +5 -0
  273. package/dist/src/types/index.js +14 -0
  274. package/dist/src/types/plan.js +1 -0
  275. package/dist/src/types/request.js +1 -0
  276. package/dist/src/types/response.js +1 -0
  277. package/dist/src/types/service.js +1 -0
  278. package/dist/src/ui/App.js +1672 -0
  279. package/dist/src/ui/DeployPreview.js +60 -0
  280. package/dist/src/ui/FileDiffModal.js +108 -0
  281. package/dist/src/ui/Header.js +46 -0
  282. package/dist/src/ui/HelpModal.js +9 -0
  283. package/dist/src/ui/InputBox.js +408 -0
  284. package/dist/src/ui/MessageList.js +795 -0
  285. package/dist/src/ui/PermissionPrompt.js +72 -0
  286. package/dist/src/ui/StatusBar.js +109 -0
  287. package/dist/src/ui/TerminalPane.js +31 -0
  288. package/dist/src/ui/ToolCallDisplay.js +303 -0
  289. package/dist/src/ui/TreePane.js +83 -0
  290. package/dist/src/ui/chat-ui.js +721 -0
  291. package/dist/src/ui/index.js +11 -0
  292. package/dist/src/ui/ink/index.js +1325 -0
  293. package/dist/src/ui/streaming.js +137 -0
  294. package/dist/src/ui/theme.js +78 -0
  295. package/dist/src/ui/types.js +7 -0
  296. package/dist/src/utils/analytics.js +61 -0
  297. package/dist/src/utils/cost-warning.js +25 -0
  298. package/dist/src/utils/env.js +42 -0
  299. package/dist/src/utils/errors.js +54 -0
  300. package/dist/src/utils/event-bus.js +22 -0
  301. package/dist/src/utils/index.js +16 -0
  302. package/dist/src/utils/logger.js +150 -0
  303. package/dist/src/utils/rate-limiter.js +90 -0
  304. package/dist/src/utils/service-auth.js +36 -0
  305. package/dist/src/utils/validation.js +39 -0
  306. package/dist/src/version.js +3 -0
  307. package/dist/src/watcher/index.js +192 -0
  308. package/dist/src/wizard/approval.js +275 -0
  309. package/dist/src/wizard/index.js +13 -0
  310. package/dist/src/wizard/prompts.js +273 -0
  311. package/dist/src/wizard/types.js +4 -0
  312. package/dist/src/wizard/ui.js +453 -0
  313. package/dist/src/wizard/wizard.js +227 -0
  314. package/package.json +31 -23
  315. package/src/__tests__/alias.test.ts +133 -0
  316. package/src/__tests__/app.test.ts +1 -1
  317. package/src/__tests__/audit.test.ts +1 -1
  318. package/src/__tests__/circuit-breaker.test.ts +1 -1
  319. package/src/__tests__/cli-run.test.ts +237 -1
  320. package/src/__tests__/compat-sqlite.test.ts +68 -0
  321. package/src/__tests__/context-manager.test.ts +131 -1
  322. package/src/__tests__/context.test.ts +1 -1
  323. package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
  324. package/src/__tests__/doctor.test.ts +48 -0
  325. package/src/__tests__/enterprise.test.ts +1 -1
  326. package/src/__tests__/export.test.ts +236 -0
  327. package/src/__tests__/gap-11-18-20.test.ts +958 -0
  328. package/src/__tests__/generator.test.ts +1 -1
  329. package/src/__tests__/helm-streaming.test.ts +127 -0
  330. package/src/__tests__/hooks.test.ts +1 -1
  331. package/src/__tests__/incident.test.ts +179 -0
  332. package/src/__tests__/init.test.ts +55 -4
  333. package/src/__tests__/intent-parser.test.ts +1 -1
  334. package/src/__tests__/llm-router.test.ts +1 -1
  335. package/src/__tests__/logs.test.ts +107 -0
  336. package/src/__tests__/loop-errors.test.ts +244 -0
  337. package/src/__tests__/lsp.test.ts +1 -1
  338. package/src/__tests__/modes.test.ts +1 -1
  339. package/src/__tests__/perf-optimizations.test.ts +847 -0
  340. package/src/__tests__/permissions.test.ts +1 -1
  341. package/src/__tests__/pipeline.test.ts +50 -0
  342. package/src/__tests__/polish-phase3.test.ts +340 -0
  343. package/src/__tests__/profile.test.ts +237 -0
  344. package/src/__tests__/rollback.test.ts +83 -0
  345. package/src/__tests__/runbook.test.ts +219 -0
  346. package/src/__tests__/schedule.test.ts +206 -0
  347. package/src/__tests__/serve.test.ts +1 -1
  348. package/src/__tests__/sessions.test.ts +96 -1
  349. package/src/__tests__/sharing.test.ts +53 -1
  350. package/src/__tests__/snapshots.test.ts +1 -1
  351. package/src/__tests__/standalone-migration.test.ts +199 -0
  352. package/src/__tests__/state-db.test.ts +1 -1
  353. package/src/__tests__/status.test.ts +158 -0
  354. package/src/__tests__/stream-with-tools.test.ts +71 -25
  355. package/src/__tests__/subagents.test.ts +1 -1
  356. package/src/__tests__/system-prompt.test.ts +82 -3
  357. package/src/__tests__/terminal-gap-v2.test.ts +395 -0
  358. package/src/__tests__/terminal-parity.test.ts +393 -0
  359. package/src/__tests__/tf-apply.test.ts +187 -0
  360. package/src/__tests__/tool-converter.test.ts +1 -1
  361. package/src/__tests__/tool-schemas.test.ts +209 -4
  362. package/src/__tests__/tools.test.ts +4 -3
  363. package/src/__tests__/version-json.test.ts +184 -0
  364. package/src/__tests__/version.test.ts +1 -1
  365. package/src/__tests__/watch.test.ts +129 -0
  366. package/src/agent/compaction-agent.ts +40 -1
  367. package/src/agent/context-manager.ts +67 -3
  368. package/src/agent/deploy-preview.ts +62 -1
  369. package/src/agent/expand-files.ts +108 -0
  370. package/src/agent/loop.ts +1312 -31
  371. package/src/agent/permissions.ts +51 -4
  372. package/src/agent/system-prompt.ts +573 -19
  373. package/src/app.ts +58 -0
  374. package/src/audit/security-scanner.ts +45 -0
  375. package/src/auth/keychain.ts +82 -0
  376. package/src/auth/oauth.ts +15 -5
  377. package/src/cli/init.ts +378 -5
  378. package/src/cli/run.ts +407 -16
  379. package/src/cli/serve.ts +78 -1
  380. package/src/cli/web.ts +10 -6
  381. package/src/cli.ts +312 -1
  382. package/src/clients/service-discovery.ts +30 -25
  383. package/src/commands/alias.ts +100 -0
  384. package/src/commands/audit/index.ts +121 -2
  385. package/src/commands/auth-cloud.ts +113 -0
  386. package/src/commands/auth-refresh.ts +187 -0
  387. package/src/commands/aws-discover.ts +144 -251
  388. package/src/commands/aws-terraform.ts +68 -118
  389. package/src/commands/chat.ts +9 -3
  390. package/src/commands/completions.ts +268 -0
  391. package/src/commands/config.ts +26 -0
  392. package/src/commands/cost/index.ts +218 -2
  393. package/src/commands/deploy.ts +260 -0
  394. package/src/commands/doctor.ts +744 -152
  395. package/src/commands/drift/index.ts +371 -23
  396. package/src/commands/export.ts +146 -0
  397. package/src/commands/generate-k8s.ts +9 -61
  398. package/src/commands/generate-terraform.ts +191 -449
  399. package/src/commands/help.ts +212 -36
  400. package/src/commands/history.ts +8 -1
  401. package/src/commands/incident.ts +166 -0
  402. package/src/commands/init.ts +5 -0
  403. package/src/commands/login.ts +86 -1
  404. package/src/commands/logs.ts +167 -0
  405. package/src/commands/onboarding.ts +211 -34
  406. package/src/commands/pipeline.ts +186 -0
  407. package/src/commands/plugin.ts +398 -0
  408. package/src/commands/profile.ts +342 -0
  409. package/src/commands/questionnaire.ts +0 -98
  410. package/src/commands/resume.ts +26 -34
  411. package/src/commands/rollback.ts +315 -0
  412. package/src/commands/rollout.ts +88 -0
  413. package/src/commands/runbook.ts +346 -0
  414. package/src/commands/schedule.ts +236 -0
  415. package/src/commands/status.ts +252 -0
  416. package/src/commands/team-context.ts +220 -0
  417. package/src/commands/template.ts +58 -57
  418. package/src/commands/tf/index.ts +70 -11
  419. package/src/commands/upgrade.ts +57 -0
  420. package/src/commands/version.ts +54 -50
  421. package/src/commands/watch.ts +153 -0
  422. package/src/compat/runtime.ts +1 -1
  423. package/src/compat/sqlite.ts +75 -5
  424. package/src/config/mode-store.ts +62 -0
  425. package/src/config/profiles.ts +84 -0
  426. package/src/config/types.ts +83 -1
  427. package/src/config/workspace-state.ts +53 -0
  428. package/src/engine/cost-estimator.ts +52 -10
  429. package/src/engine/executor.ts +33 -2
  430. package/src/engine/planner.ts +68 -1
  431. package/src/generator/terraform.ts +8 -0
  432. package/src/history/manager.ts +2 -74
  433. package/src/hooks/engine.ts +5 -4
  434. package/src/llm/cost-calculator.ts +2 -2
  435. package/src/llm/providers/anthropic.ts +50 -21
  436. package/src/llm/router.ts +76 -7
  437. package/src/lsp/languages.ts +3 -0
  438. package/src/lsp/manager.ts +21 -5
  439. package/src/nimbus.ts +37 -18
  440. package/src/sessions/manager.ts +108 -1
  441. package/src/sharing/sync.ts +4 -0
  442. package/src/sharing/viewer.ts +66 -0
  443. package/src/tools/file-ops.ts +22 -0
  444. package/src/tools/schemas/devops.ts +3007 -117
  445. package/src/tools/schemas/standard.ts +5 -1
  446. package/src/tools/schemas/types.ts +31 -1
  447. package/src/tools/spawn-exec.ts +148 -0
  448. package/src/ui/App.tsx +1183 -66
  449. package/src/ui/DeployPreview.tsx +62 -57
  450. package/src/ui/FileDiffModal.tsx +162 -0
  451. package/src/ui/Header.tsx +87 -24
  452. package/src/ui/HelpModal.tsx +57 -0
  453. package/src/ui/InputBox.tsx +163 -10
  454. package/src/ui/MessageList.tsx +487 -40
  455. package/src/ui/PermissionPrompt.tsx +17 -5
  456. package/src/ui/StatusBar.tsx +122 -3
  457. package/src/ui/TerminalPane.tsx +84 -0
  458. package/src/ui/ToolCallDisplay.tsx +252 -18
  459. package/src/ui/TreePane.tsx +132 -0
  460. package/src/ui/chat-ui.ts +41 -44
  461. package/src/ui/ink/index.ts +771 -38
  462. package/src/ui/streaming.ts +1 -1
  463. package/src/ui/theme.ts +104 -0
  464. package/src/ui/types.ts +18 -0
  465. package/src/version.ts +1 -1
  466. package/src/watcher/index.ts +66 -15
  467. package/src/wizard/types.ts +1 -0
  468. package/src/wizard/ui.ts +1 -1
  469. package/tsconfig.json +2 -2
@@ -0,0 +1,1535 @@
1
+ /**
2
+ * Core Agentic Loop
3
+ *
4
+ * Implements the autonomous agent loop:
5
+ * 1. Build context (system prompt + history + tools)
6
+ * 2. Send to LLM with tools enabled
7
+ * 3. Stream text response
8
+ * 4. If tool_use: check permissions → execute → collect results
9
+ * 5. Append messages → loop back to LLM
10
+ * 6. Exit when LLM returns end_turn (no more tool calls)
11
+ *
12
+ * This is the heart of the Nimbus agent. Every user message enters
13
+ * {@link runAgentLoop}, which orchestrates a multi-turn conversation with
14
+ * the LLM, executing tools on its behalf until it signals completion by
15
+ * returning a response with no further tool calls.
16
+ *
17
+ * @module agent/loop
18
+ */
19
+ import { join } from 'node:path';
20
+ import { toOpenAITool, } from '../tools/schemas/types';
21
+ import { buildSystemPrompt } from './system-prompt';
22
+ import { runCompaction } from './compaction-agent';
23
+ import { SnapshotManager } from '../snapshots/manager';
24
+ import { calculateCost } from '../llm/cost-calculator';
25
+ import { runPreToolHooks, runPostToolHooks, } from '../hooks/engine';
26
+ import { maskSecrets } from '../audit/security-scanner';
27
+ import { classifyTaskComplexity, routeModel } from '../llm/router';
28
+ import { mkdirSync as _cpMkdirSync, writeFileSync as _cpWriteFileSync } from 'node:fs';
29
+ import { homedir as _cpHomedir } from 'node:os';
30
+ // ---------------------------------------------------------------------------
31
+ // C2: Infra state checkpoint helper
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Write a checkpoint JSON file to ~/.nimbus/infra-checkpoints/<timestamp>.json
35
+ * before a mutating terraform or helm operation. Non-blocking — errors are swallowed.
36
+ */
37
+ function writeInfraCheckpoint(tool, action, input) {
38
+ try {
39
+ const checkpointsDir = join(_cpHomedir(), '.nimbus', 'infra-checkpoints');
40
+ _cpMkdirSync(checkpointsDir, { recursive: true });
41
+ // Sanitize: remove any field that looks like a secret
42
+ const sanitized = {};
43
+ for (const [k, v] of Object.entries(input)) {
44
+ const lower = k.toLowerCase();
45
+ if (lower.includes('secret') || lower.includes('password') || lower.includes('token') || lower.includes('key')) {
46
+ sanitized[k] = '[redacted]';
47
+ }
48
+ else {
49
+ sanitized[k] = v;
50
+ }
51
+ }
52
+ const timestamp = new Date().toISOString();
53
+ const checkpoint = {
54
+ timestamp,
55
+ tool,
56
+ action,
57
+ input: sanitized,
58
+ cwd: process.cwd(),
59
+ workdir: input.workdir ?? undefined,
60
+ };
61
+ const fileName = timestamp.replace(/[:.]/g, '-') + '.json';
62
+ _cpWriteFileSync(join(checkpointsDir, fileName), JSON.stringify(checkpoint, null, 2), 'utf-8');
63
+ }
64
+ catch { /* non-critical */ }
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+ // ---------------------------------------------------------------------------
70
+ // Module-level compiled regex constants for classifyDevOpsError (PERF-1d).
71
+ // Hoisted here so they compile once at module load rather than per-call.
72
+ // ---------------------------------------------------------------------------
73
+ const _RE_CREDENTIAL_EXPIRY_AWS = /ExpiredTokenException|TokenExpiredException|token.*has.*expired/i;
74
+ const _RE_CREDENTIAL_EXPIRY_GCP = /credentials.*expired|Application Default Credentials.*expired|re-authenticate/i;
75
+ const _RE_CREDENTIAL_EXPIRY_AZURE = /AADSTS70008|InteractionRequired|credential.*expired/i;
76
+ const _RE_CMD_NOT_FOUND = /command not found|not found|no such file or directory/i;
77
+ /**
78
+ * Classify a DevOps tool error and return an actionable hint for the LLM.
79
+ * Returns null for unrecognized errors so we don't pollute the context.
80
+ */
81
+ function classifyDevOpsError(toolName, errorOutput, nimbusInstructions) {
82
+ const e = errorOutput.toLowerCase();
83
+ // GAP-13: Credential expiry patterns — must come first for fast matching
84
+ const CREDENTIAL_EXPIRY = [
85
+ { re: _RE_CREDENTIAL_EXPIRY_AWS, provider: 'aws' },
86
+ { re: _RE_CREDENTIAL_EXPIRY_GCP, provider: 'gcp' },
87
+ { re: _RE_CREDENTIAL_EXPIRY_AZURE, provider: 'azure' },
88
+ ];
89
+ for (const { re, provider } of CREDENTIAL_EXPIRY) {
90
+ if (re.test(errorOutput)) {
91
+ return `Your ${provider.toUpperCase()} credentials have expired.\n\nRun: \`nimbus auth-refresh --provider ${provider}\` to refresh them.`;
92
+ }
93
+ }
94
+ // G3: "command not found" — provide installation hints for DevOps CLIs
95
+ const INSTALL_HINTS = {
96
+ terraform: 'brew install terraform OR https://developer.hashicorp.com/terraform/install',
97
+ kubectl: 'brew install kubectl OR https://kubernetes.io/docs/tasks/tools/',
98
+ helm: 'brew install helm OR https://helm.sh/docs/intro/install/',
99
+ docker: 'brew install --cask docker OR https://docs.docker.com/get-docker/',
100
+ aws: 'brew install awscli OR pip install awscli',
101
+ gcloud: 'brew install --cask google-cloud-sdk',
102
+ az: 'brew install azure-cli',
103
+ };
104
+ if (_RE_CMD_NOT_FOUND.test(errorOutput)) {
105
+ for (const [cmd, hint] of Object.entries(INSTALL_HINTS)) {
106
+ if (toolName.includes(cmd) || e.includes(`'${cmd}'`) || e.includes(`"${cmd}"`)) {
107
+ return `\`${cmd}\` is not installed.\n\nInstall: ${hint}`;
108
+ }
109
+ }
110
+ }
111
+ // Terraform errors
112
+ if (toolName === 'terraform' || e.includes('terraform')) {
113
+ if (e.includes('no such file or directory') && e.includes('.terraform')) {
114
+ return 'HINT: Run `terraform init` first — the .terraform directory is missing.';
115
+ }
116
+ if (e.includes('provider') && e.includes('required') && e.includes('terraform')) {
117
+ return 'HINT: Run `terraform init -upgrade` to download or upgrade required providers.';
118
+ }
119
+ if (e.includes('no valid credential') || e.includes('no credentials')) {
120
+ return 'HINT: AWS/cloud credentials are missing. Check `aws configure` or environment variables.';
121
+ }
122
+ if (e.includes('state lock') || e.includes('lock file')) {
123
+ return 'HINT: Terraform state is locked. If no other operation is running, use `terraform force-unlock <lock-id>`.';
124
+ }
125
+ if (e.includes('module not installed') || e.includes('module source')) {
126
+ return 'HINT: Run `terraform init` to install required modules.';
127
+ }
128
+ if (e.includes('quota') || e.includes('limit exceeded') || e.includes('vcpu')) {
129
+ return 'HINT: Cloud resource quota exceeded. Request a limit increase in the cloud console.';
130
+ }
131
+ }
132
+ // Kubernetes errors
133
+ if (toolName === 'kubectl' || toolName === 'kubectl_context') {
134
+ if (e.includes('connection refused') || e.includes('unable to connect')) {
135
+ return 'HINT: Cannot reach the Kubernetes API server. Check `kubectl config current-context` and ensure the cluster is accessible.';
136
+ }
137
+ if (e.includes('unauthorized') || e.includes('forbidden')) {
138
+ return 'HINT: Insufficient permissions. Check your kubeconfig credentials or RBAC roles.';
139
+ }
140
+ if (e.includes('not found') && e.includes('namespace')) {
141
+ return 'HINT: The namespace does not exist. Create it with `kubectl create namespace <name>` first.';
142
+ }
143
+ if (e.includes('image') && (e.includes('not found') || e.includes('pull'))) {
144
+ return 'HINT: Container image pull failed. Verify the image name, tag, and registry credentials (imagePullSecret).';
145
+ }
146
+ }
147
+ // Helm errors
148
+ if (toolName === 'helm' || toolName === 'helm_values') {
149
+ if (e.includes('chart not found') || e.includes('no such chart')) {
150
+ return 'HINT: Chart not found. Run `helm repo update` and verify the chart name.';
151
+ }
152
+ if (e.includes('release not found')) {
153
+ return 'HINT: Helm release not found. Use `helm list -A` to see all releases across namespaces.';
154
+ }
155
+ if (e.includes('unable to build kubernetes objects') || e.includes('manifest')) {
156
+ return 'HINT: Helm template rendering failed. Run `helm template <release> <chart>` to debug the manifests.';
157
+ }
158
+ }
159
+ // Cloud CLI errors
160
+ if (toolName === 'cloud_discover' || toolName === 'cloud_action') {
161
+ if (e.includes('not authorized') || e.includes('access denied') || e.includes('unauthorized')) {
162
+ return 'HINT: Cloud credentials lack required permissions. Check IAM policies/roles for the operation.';
163
+ }
164
+ if (e.includes('region') && e.includes('not found')) {
165
+ return 'HINT: Invalid region. Check `aws configure get region` or pass --region explicitly.';
166
+ }
167
+ }
168
+ // Docker errors
169
+ if (toolName === 'docker') {
170
+ if (e.includes('cannot connect to the docker daemon') || e.includes('docker daemon') || e.includes('docker.sock')) {
171
+ return 'HINT: Docker daemon is not running. Start it with `colima start` (macOS) or `sudo systemctl start docker` (Linux).';
172
+ }
173
+ if (e.includes('manifest unknown') || e.includes('manifest not found') || e.includes('not found')) {
174
+ return 'HINT: Image not found. Verify the image name and tag. Check registry credentials with `docker login`.';
175
+ }
176
+ if (e.includes('no space left on device') || e.includes('no space left')) {
177
+ return 'HINT: Docker disk space exhausted. Run `docker system prune -f` to reclaim space.';
178
+ }
179
+ if (e.includes('permission denied') && e.includes('docker')) {
180
+ return 'HINT: Docker permission denied. Add your user to the docker group: `sudo usermod -aG docker $USER`.';
181
+ }
182
+ }
183
+ // Secrets errors
184
+ if (toolName === 'secrets') {
185
+ if (e.includes('permission denied') || e.includes('403') || e.includes('accessdenied')) {
186
+ return 'HINT: Secrets access denied. Check Vault policy with `vault policy read <policy>` or IAM role permissions.';
187
+ }
188
+ if (e.includes('secret not found') || e.includes('no such secret') || e.includes('resourcenotfoundexception')) {
189
+ return 'HINT: Secret not found. Verify the secret path/name and namespace. Use `vault kv list <mount>` to browse.';
190
+ }
191
+ if (e.includes('invalid token') || e.includes('token expired')) {
192
+ return 'HINT: Vault/cloud token expired. Run `vault login` or refresh cloud credentials with `nimbus auth-refresh`.';
193
+ }
194
+ }
195
+ // CI/CD errors
196
+ if (toolName === 'cicd') {
197
+ if (e.includes('workflow not found') || e.includes('could not find workflow')) {
198
+ return 'HINT: Workflow not found. Check the workflow filename in .github/workflows/ and the branch name.';
199
+ }
200
+ if (e.includes('rate limit') || e.includes('429') || e.includes('too many requests')) {
201
+ return 'HINT: API rate limited. Wait 60 seconds and retry. Check rate limit headers for reset time.';
202
+ }
203
+ if (e.includes('unauthorized') || e.includes('401') || e.includes('bad credentials')) {
204
+ return 'HINT: CI/CD authentication failed. Check GITHUB_TOKEN, GITLAB_TOKEN, or CIRCLECI_TOKEN environment variables.';
205
+ }
206
+ }
207
+ // GitOps errors
208
+ if (toolName === 'gitops') {
209
+ if (e.includes('not found') || e.includes('not logged in') || e.includes('unauthenticated')) {
210
+ return 'HINT: ArgoCD/Flux not accessible. Check ARGOCD_SERVER and ARGOCD_TOKEN env vars, or run `argocd login`.';
211
+ }
212
+ if (e.includes('comparisonerror') || e.includes('sync error')) {
213
+ return 'HINT: GitOps sync error. Validate manifests: `kubectl apply --dry-run=client -f <manifest>` to find issues.';
214
+ }
215
+ if (e.includes('health') && e.includes('degraded')) {
216
+ return 'HINT: Application is degraded. Check pod logs with `kubectl logs -n <ns>` and events with `kubectl get events -n <ns>`.';
217
+ }
218
+ }
219
+ // Monitoring errors
220
+ if (toolName === 'monitor') {
221
+ if (e.includes('connection refused') || e.includes('could not connect')) {
222
+ return 'HINT: Cannot connect to monitoring endpoint. Check PROMETHEUS_URL, GRAFANA_URL, or cloud region configuration.';
223
+ }
224
+ if (e.includes('unauthorized') || e.includes('403')) {
225
+ return 'HINT: Monitoring authentication failed. Check DD_API_KEY, GRAFANA_TOKEN, or NEW_RELIC_API_KEY environment variables.';
226
+ }
227
+ }
228
+ // L3: Parse NIMBUS.md custom error hints section
229
+ if (nimbusInstructions) {
230
+ const hintsMatch = nimbusInstructions.match(/##\s*Custom Error Hints\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
231
+ if (hintsMatch) {
232
+ const hintsSection = hintsMatch[1];
233
+ const hintLines = hintsSection.split('\n').filter(l => l.trim().startsWith('-'));
234
+ for (const line of hintLines) {
235
+ // Format: "- pattern: hint message"
236
+ const colonIdx = line.indexOf(':');
237
+ if (colonIdx > 0) {
238
+ const pattern = line.slice(1, colonIdx).trim();
239
+ const hint = line.slice(colonIdx + 1).trim();
240
+ if (pattern && hint && errorOutput.toLowerCase().includes(pattern.toLowerCase())) {
241
+ return `HINT: ${hint}`;
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+ /** DevOps tool names that get self-diagnosis hints on unrecognized errors. */
250
+ const DEVOPS_TOOL_NAMES = new Set([
251
+ 'terraform', 'kubectl', 'kubectl_context', 'helm', 'helm_values',
252
+ 'bash', 'cloud_discover', 'drift_detect', 'deploy_preview',
253
+ 'docker', 'secrets', 'cicd', 'monitor', 'gitops', 'cloud_action',
254
+ 'logs', 'certs', 'mesh', 'cfn', 'k8s_rbac',
255
+ ]);
256
+ /**
257
+ * Format a Zod (or generic) tool-input validation error into a human-readable
258
+ * message that tells the LLM exactly which fields are wrong and how to fix them.
259
+ */
260
+ function formatToolInputError(toolName, err) {
261
+ if (err && typeof err === 'object' && 'issues' in err) {
262
+ // ZodError
263
+ const issues = err.issues;
264
+ const details = issues
265
+ .map(i => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
266
+ .join('\n');
267
+ return `Tool "${toolName}" received invalid input:\n${details}\n\nPlease correct the arguments and retry.`;
268
+ }
269
+ return `Tool "${toolName}" failed: ${err instanceof Error ? err.message : String(err)}`;
270
+ }
271
+ /** Determine whether a streaming error is transient and worth retrying. */
272
+ function isRetryableStreamError(err) {
273
+ if (err && typeof err === 'object') {
274
+ const e = err;
275
+ const status = (typeof e.status === 'number' ? e.status : undefined) ??
276
+ (typeof e.statusCode === 'number' ? e.statusCode : undefined);
277
+ if (status === 429 || (status !== undefined && status >= 500 && status < 600))
278
+ return true;
279
+ const msg = typeof e.message === 'string' ? e.message : '';
280
+ if (/rate.?limit|429|too many requests|overloaded|503/i.test(msg))
281
+ return true;
282
+ }
283
+ return false;
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // G3: Runaway protection helpers
287
+ // ---------------------------------------------------------------------------
288
+ /** Patterns that indicate a destructive operation in tool arguments. */
289
+ const DESTRUCTIVE_PATTERNS = /\b(apply|destroy|delete|terminate|stop|remove|drop|truncate|purge)\b/i;
290
+ /** Tool names whose destructive operations should be counted at the session level. */
291
+ const DESTRUCTIVE_TOOL_NAMES = new Set([
292
+ 'terraform', 'kubectl', 'docker', 'aws', 'gcloud', 'az', 'cloud_action', 'cfn',
293
+ ]);
294
+ /**
295
+ * Returns true if the tool call looks like a destructive infrastructure operation.
296
+ * Used to enforce the session-level destructive ops counter (G3).
297
+ */
298
+ function isDestructiveOp(toolName, inputStr) {
299
+ return DESTRUCTIVE_TOOL_NAMES.has(toolName) && DESTRUCTIVE_PATTERNS.test(inputStr);
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // Constants
303
+ // ---------------------------------------------------------------------------
304
+ /** Default model when none is specified. */
305
+ const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-20250514';
306
+ // ---------------------------------------------------------------------------
307
+ // H5: Cost delta hint after terraform apply / helm upgrade
308
+ // ---------------------------------------------------------------------------
309
+ /**
310
+ * Extract a lightweight cost hint from tool output for display after
311
+ * infrastructure operations (terraform apply, helm install/upgrade).
312
+ */
313
+ function extractCostHintFromToolOutput(toolName, input, output) {
314
+ // terraform apply: parse "Apply complete! Resources: N added, M changed, K destroyed."
315
+ if (toolName === 'terraform' && String(input.action) === 'apply') {
316
+ const m = output.match(/Resources:\s*(\d+) added,\s*(\d+) changed,\s*(\d+) destroyed/);
317
+ if (m) {
318
+ const added = Number(m[1]);
319
+ const changed = Number(m[2]);
320
+ const destroyed = Number(m[3]);
321
+ const parts = [];
322
+ if (added > 0)
323
+ parts.push(`+${added} resources created`);
324
+ if (changed > 0)
325
+ parts.push(`${changed} updated`);
326
+ if (destroyed > 0)
327
+ parts.push(`${destroyed} destroyed`);
328
+ return parts.length > 0
329
+ ? `${parts.join(', ')} — run "nimbus cost" for monthly cost estimate`
330
+ : null;
331
+ }
332
+ }
333
+ // helm install/upgrade
334
+ if (toolName === 'helm' && ['install', 'upgrade'].includes(String(input.action))) {
335
+ const releaseName = String(input.releaseName ?? input.release ?? '');
336
+ if (!output.includes('Error') && !output.includes('FAILED')) {
337
+ return `Helm release "${releaseName}" deployed — run "nimbus cost" for estimated cost impact`;
338
+ }
339
+ }
340
+ return null;
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // M4: Session-scoped error tracking for NIMBUS.md persistence
344
+ // ---------------------------------------------------------------------------
345
+ const sessionErrorCounts = new Map();
346
+ function trackAndPersistError(toolName, errorHint, cwd) {
347
+ const key = `${toolName}:${errorHint.slice(0, 60)}`;
348
+ const count = (sessionErrorCounts.get(key) ?? 0) + 1;
349
+ sessionErrorCounts.set(key, count);
350
+ if (count === 3) {
351
+ try {
352
+ const { existsSync, readFileSync, writeFileSync, appendFileSync } = require('node:fs');
353
+ const { join } = require('node:path');
354
+ const nimbusPath = join(cwd, 'NIMBUS.md');
355
+ if (!existsSync(nimbusPath))
356
+ return;
357
+ const existing = readFileSync(nimbusPath, 'utf-8');
358
+ if (existing.includes(errorHint.slice(0, 40)))
359
+ return; // already recorded
360
+ const entry = `- ${toolName}: ${errorHint}\n`;
361
+ if (existing.includes('## Observed Issues')) {
362
+ writeFileSync(nimbusPath, existing.replace('## Observed Issues\n', `## Observed Issues\n${entry}`));
363
+ }
364
+ else {
365
+ appendFileSync(nimbusPath, `\n## Observed Issues\n${entry}`);
366
+ }
367
+ }
368
+ catch { /* non-critical */ }
369
+ }
370
+ }
371
+ // ---------------------------------------------------------------------------
372
+ // M6: Destructive action guard — force confirmation before terraform destroy / kubectl delete
373
+ // ---------------------------------------------------------------------------
374
+ function isDestructiveAction(toolName, input) {
375
+ const action = String(input.action ?? input.command ?? '');
376
+ if (toolName === 'terraform' && action === 'destroy') {
377
+ return 'terraform destroy will PERMANENTLY DELETE all managed infrastructure. Explicitly confirm with the user before proceeding.';
378
+ }
379
+ if (toolName === 'kubectl' && action === 'delete') {
380
+ const resource = String(input.resource ?? '');
381
+ return `kubectl delete ${resource} is IRREVERSIBLE. Explicitly confirm with the user before proceeding.`;
382
+ }
383
+ if (toolName === 'helm' && action === 'uninstall') {
384
+ return 'helm uninstall will remove the release and its resources. Explicitly confirm with the user before proceeding.';
385
+ }
386
+ return null;
387
+ }
388
+ const PLAN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
389
+ const terraformPlanCache = new Map();
390
+ /** Store a terraform plan output for a workdir. */
391
+ function cacheTerraformPlan(workdir, output) {
392
+ terraformPlanCache.set(workdir, { output, workdir, timestamp: Date.now() });
393
+ }
394
+ /** Retrieve a cached terraform plan for a workdir, or null if expired/missing. */
395
+ function getCachedTerraformPlan(workdir) {
396
+ const entry = terraformPlanCache.get(workdir);
397
+ if (!entry)
398
+ return null;
399
+ if (Date.now() - entry.timestamp > PLAN_CACHE_TTL_MS) {
400
+ terraformPlanCache.delete(workdir);
401
+ return null;
402
+ }
403
+ return entry.output;
404
+ }
405
+ /**
406
+ * Background interval that evicts expired terraform plan cache entries every 60s.
407
+ * `.unref()` ensures this does not prevent the process from exiting.
408
+ * Exported for test teardown.
409
+ */
410
+ export const _planCacheCleanupInterval = setInterval(() => {
411
+ const now = Date.now();
412
+ for (const [key, entry] of terraformPlanCache) {
413
+ if (now - entry.timestamp > PLAN_CACHE_TTL_MS) {
414
+ terraformPlanCache.delete(key);
415
+ }
416
+ }
417
+ }, 60_000).unref();
418
+ /** Default max output tokens per LLM call. */
419
+ const DEFAULT_MAX_TOKENS = 8192;
420
+ /** Default maximum number of agent turns. */
421
+ const DEFAULT_MAX_TURNS = 50;
422
+ /** Maximum characters of tool output to include in conversation history.
423
+ * Anything beyond this is truncated to prevent context window overflow. */
424
+ const MAX_TOOL_OUTPUT_CHARS = 100_000;
425
+ // ---------------------------------------------------------------------------
426
+ // Main Entry Point
427
+ // ---------------------------------------------------------------------------
428
+ /**
429
+ * Run the agentic loop.
430
+ *
431
+ * Takes a user message and existing conversation history, then runs
432
+ * the LLM in a loop until it stops requesting tool calls.
433
+ *
434
+ * The loop terminates when any of the following conditions are met:
435
+ * - The LLM returns a response with no tool calls (natural end).
436
+ * - The maximum number of turns is reached.
437
+ * - The AbortSignal fires (e.g. user presses Ctrl+C).
438
+ * - An unrecoverable LLM API error occurs.
439
+ *
440
+ * @param userMessage - The new user message to process.
441
+ * @param history - Prior conversation messages (may be empty for a fresh session).
442
+ * @param options - Configuration for the loop.
443
+ * @returns The final conversation state, turn count, usage, and cost.
444
+ */
445
+ export async function runAgentLoop(userMessage, history, options) {
446
+ const { router, toolRegistry, mode, maxTurns = DEFAULT_MAX_TURNS, model, cwd, nimbusInstructions, onText, onToolCallStart, onToolCallEnd, onToolOutputChunk, checkPermission, signal, } = options;
447
+ // -----------------------------------------------------------------------
448
+ // 1. Prepare tools and system prompt
449
+ // -----------------------------------------------------------------------
450
+ const tools = getToolsForMode(toolRegistry.getAll(), mode);
451
+ // H3: Auto-discover infra context if not provided and cwd is set (best-effort, cached per cwd)
452
+ let resolvedInfraContext = options.infraContext;
453
+ if (!resolvedInfraContext && cwd) {
454
+ try {
455
+ const { discoverInfraContext } = await import('../cli/init');
456
+ resolvedInfraContext = await Promise.race([
457
+ discoverInfraContext(cwd),
458
+ new Promise(r => setTimeout(() => r(undefined), 5000)),
459
+ ]);
460
+ }
461
+ catch { /* best-effort */ }
462
+ }
463
+ const systemPrompt = buildSystemPrompt({
464
+ mode,
465
+ tools,
466
+ nimbusInstructions,
467
+ cwd,
468
+ infraContext: resolvedInfraContext,
469
+ dryRun: options.dryRun,
470
+ });
471
+ // Convert agentic ToolDefinitions to the LLM-level format expected by
472
+ // the router's routeWithTools() method (OpenAI function-calling shape).
473
+ const llmTools = tools.map(toOpenAITool);
474
+ // -----------------------------------------------------------------------
475
+ // 2. Initialize conversation state
476
+ // -----------------------------------------------------------------------
477
+ // PERF-4a: Capacity-hinted pre-allocation avoids repeated V8 array reallocation
478
+ // as messages accumulate during a long conversation.
479
+ const messages = new Array(Math.max(history.length + 1, 10));
480
+ messages.length = 0;
481
+ messages.push(...history, { role: 'user', content: userMessage });
482
+ let turns = 0;
483
+ let interrupted = false;
484
+ const totalUsage = {
485
+ promptTokens: 0,
486
+ completionTokens: 0,
487
+ totalTokens: 0,
488
+ };
489
+ let totalCost = 0;
490
+ // G3: Session-level destructive operation counter and per-turn tool call counter
491
+ let sessionDestructiveOps = 0;
492
+ const MAX_TOOL_CALLS_PER_TURN = options.maxToolCallsPerTurn ?? 20;
493
+ const MAX_DESTRUCTIVE_OPS_PER_SESSION = options.maxDestructiveOpsPerSession ?? 5;
494
+ // M2/M5: Track tool calls that have already received a credential-error retry message
495
+ // to avoid spamming the auth-refresh hint on repeated failures.
496
+ const credentialRetried = new Set();
497
+ // G8: Track which terraform workdirs have had a plan run in this session.
498
+ // Used to warn when apply is run without a prior plan.
499
+ const terraformPlannedWorkdirs = new Set();
500
+ // G10: One-time kubectl RBAC pre-flight check state.
501
+ // kubectlRbacChecked: ensures we only run `kubectl auth can-i --list` once per session.
502
+ // rbacPreamble: stores the RBAC output to inject into the first kubectl tool result.
503
+ let kubectlRbacChecked = false;
504
+ let rbacPreamble = '';
505
+ // G10: Pre-import async exec utilities so they're available inside the loop.
506
+ // Using async execFile avoids blocking the Node.js event loop for kubectl/terraform calls.
507
+ const { execFile: _execFile, exec: _exec } = await import('node:child_process');
508
+ const { promisify: _promisify } = await import('node:util');
509
+ const _execFileAsync = _promisify(_execFile);
510
+ const _execAsync = _promisify(_exec);
511
+ // PERF-4a: Pre-build the system message once so it can be reused every turn
512
+ // without allocating a new object on each loop iteration.
513
+ const _systemMessageObj = { role: 'system', content: systemPrompt };
514
+ // Shared mutable ref: set to true by 'apply-all' diff decision to skip further prompts
515
+ const skipRemainingDiffPrompts = { value: options.skipRemainingDiffPrompts ?? false };
516
+ // Shared mutable ref: set to true by 'reject-all' diff decision to auto-reject further prompts
517
+ const rejectRemainingDiffPrompts = { value: options.rejectRemainingDiffPrompts ?? false };
518
+ // -----------------------------------------------------------------------
519
+ // 3. Main agent loop
520
+ // -----------------------------------------------------------------------
521
+ while (turns < maxTurns) {
522
+ // Check for cancellation before each turn
523
+ if (signal?.aborted) {
524
+ interrupted = true;
525
+ break;
526
+ }
527
+ turns++;
528
+ try {
529
+ // Gap 18: Auto-route model based on task complexity when no explicit model set
530
+ let effectiveModel = model ?? DEFAULT_MODEL;
531
+ if (!model && options.autoRouteModel) {
532
+ const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
533
+ const lastMsgText = lastUserMsg
534
+ ? typeof lastUserMsg.content === 'string'
535
+ ? lastUserMsg.content
536
+ : JSON.stringify(lastUserMsg.content)
537
+ : '';
538
+ const complexity = classifyTaskComplexity(lastMsgText);
539
+ effectiveModel = routeModel(complexity);
540
+ if (onText && turns === 1) {
541
+ onText(`\n[auto: ${effectiveModel.split('/').pop()?.replace('anthropic/', '') ?? effectiveModel}]\n`);
542
+ }
543
+ }
544
+ // Build the completion request with tool definitions.
545
+ // The systemMessageObj is pre-built before the loop (PERF-4a) — reuse it.
546
+ const allMessages = new Array(messages.length + 1);
547
+ allMessages.length = 0;
548
+ allMessages.push(_systemMessageObj, ...messages);
549
+ const request = {
550
+ messages: allMessages,
551
+ model: effectiveModel,
552
+ tools: llmTools,
553
+ maxTokens: DEFAULT_MAX_TOKENS,
554
+ };
555
+ // Stream text tokens incrementally via routeStreamWithTools.
556
+ // Tokens are forwarded to onText as they arrive; tool calls
557
+ // are accumulated from the final chunk.
558
+ let responseContent = '';
559
+ let responseToolCalls;
560
+ let responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
561
+ // A1: Retry on transient errors (rate-limit / 5xx) with exponential backoff
562
+ const MAX_STREAM_RETRIES = 2;
563
+ let streamAttempt = 0;
564
+ while (true) {
565
+ // A2: Silence timeout — abort if no chunk arrives (G21: configurable)
566
+ const STREAM_SILENCE_MS = options.streamSilenceTimeoutMs ?? 60_000;
567
+ const silenceAbort = new AbortController();
568
+ let silenceTimer;
569
+ const resetSilence = () => {
570
+ clearTimeout(silenceTimer);
571
+ silenceTimer = setTimeout(() => silenceAbort.abort('Stream timeout'), STREAM_SILENCE_MS);
572
+ };
573
+ resetSilence();
574
+ try {
575
+ // Pass silence abort signal via request cast (non-standard but supported by most providers)
576
+ const requestWithSignal = { ...request, signal: silenceAbort.signal };
577
+ for await (const chunk of router.routeStreamWithTools(requestWithSignal)) {
578
+ resetSilence(); // reset on every chunk
579
+ if (chunk.content) {
580
+ responseContent += chunk.content;
581
+ if (onText) {
582
+ onText(chunk.content);
583
+ }
584
+ }
585
+ if (chunk.toolCallStart && onText) {
586
+ // Show early feedback when the LLM starts composing a tool call
587
+ onText(`\n[Preparing tool: ${chunk.toolCallStart.name}...]\n`);
588
+ }
589
+ if (chunk.toolCalls) {
590
+ responseToolCalls = chunk.toolCalls;
591
+ }
592
+ if (chunk.usage) {
593
+ responseUsage = chunk.usage;
594
+ }
595
+ }
596
+ clearTimeout(silenceTimer);
597
+ break; // success — exit retry loop
598
+ }
599
+ catch (streamErr) {
600
+ clearTimeout(silenceTimer);
601
+ if (streamAttempt < MAX_STREAM_RETRIES && isRetryableStreamError(streamErr)) {
602
+ const delay = 1000 * Math.pow(2, streamAttempt);
603
+ if (onText) {
604
+ onText(`\n[Retrying after error (attempt ${streamAttempt + 1})...]\n`);
605
+ }
606
+ await new Promise(r => setTimeout(r, delay));
607
+ streamAttempt++;
608
+ // Reset partial accumulation before retry
609
+ responseContent = '';
610
+ responseToolCalls = undefined;
611
+ responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
612
+ continue;
613
+ }
614
+ // G24: Graceful network error message instead of raw Node.js error
615
+ const streamErrObj = streamErr;
616
+ const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed|network/i.test(streamErrObj?.message ?? '');
617
+ if (isNetworkError) {
618
+ const netMsg = '\n[!!] Network unreachable — cannot reach the LLM API.\nCheck your internet connection and API key validity, then try again.\n';
619
+ if (onText)
620
+ onText(netMsg);
621
+ // Re-throw a specially-marked error so the outer turn catch block can handle it
622
+ const netErr = new Error(netMsg);
623
+ netErr._nimbusNetworkError = true;
624
+ throw netErr;
625
+ }
626
+ throw streamErr; // non-retryable — propagate to outer catch
627
+ }
628
+ }
629
+ // Accumulate usage and cost
630
+ totalUsage.promptTokens += responseUsage.promptTokens;
631
+ totalUsage.completionTokens += responseUsage.completionTokens;
632
+ totalUsage.totalTokens += responseUsage.totalTokens;
633
+ // Estimate cost for this turn
634
+ const resolvedModel = effectiveModel;
635
+ const providerName = resolvedModel.includes('/') ? resolvedModel.split('/')[0] : 'anthropic';
636
+ const modelName = resolvedModel.includes('/')
637
+ ? resolvedModel.split('/').slice(1).join('/')
638
+ : resolvedModel;
639
+ const turnCost = calculateCost(providerName, modelName, responseUsage.promptTokens, responseUsage.completionTokens);
640
+ totalCost += turnCost.costUSD;
641
+ // Notify caller of accumulated usage/cost after each turn
642
+ if (options.onUsage) {
643
+ options.onUsage(totalUsage, totalCost);
644
+ }
645
+ // M2: Emit per-turn token/cost stats as a dim system message in the TUI.
646
+ // Only emit when there was actual token usage (skip turns with 0 tokens).
647
+ if (onText && (responseUsage.promptTokens > 0 || responseUsage.completionTokens > 0)) {
648
+ const statsLine = `\n[${responseUsage.promptTokens} in / ${responseUsage.completionTokens} out — $${turnCost.costUSD.toFixed(4)}]\n`;
649
+ onText(statsLine);
650
+ }
651
+ // G16: Cost budget enforcement — stop if cumulative cost exceeds the limit
652
+ if (options.costBudgetUSD !== undefined && totalCost >= options.costBudgetUSD) {
653
+ const budgetMsg = `\n\n[!!] Cost budget of $${options.costBudgetUSD.toFixed(2)} reached (used: $${totalCost.toFixed(3)}). Stopping to prevent overspend.\n`;
654
+ if (onText)
655
+ onText(budgetMsg);
656
+ messages.push({ role: 'assistant', content: budgetMsg });
657
+ break;
658
+ }
659
+ // -----------------------------------------------------------------
660
+ // No tool calls → the LLM is done
661
+ // -----------------------------------------------------------------
662
+ if (!responseToolCalls || responseToolCalls.length === 0) {
663
+ messages.push({
664
+ role: 'assistant',
665
+ content: responseContent,
666
+ });
667
+ break;
668
+ }
669
+ // -----------------------------------------------------------------
670
+ // Tool calls present → execute each one
671
+ // -----------------------------------------------------------------
672
+ // Append the assistant message that contains the tool calls
673
+ messages.push({
674
+ role: 'assistant',
675
+ content: responseContent,
676
+ toolCalls: responseToolCalls,
677
+ });
678
+ // G3: Per-turn tool call counter — reset at the start of each tool-call batch
679
+ let turnToolCallCount = 0;
680
+ // H2: Parallel dispatch for read-only tools (safe to run concurrently)
681
+ const READ_ONLY_TOOLS = new Set([
682
+ 'read_file', 'glob', 'grep', 'cloud_discover', 'terraform_plan_analyze',
683
+ 'kubectl_context', 'helm_values', 'cost_estimate', 'drift_detect',
684
+ ]);
685
+ const canRunInParallel = (tc) => READ_ONLY_TOOLS.has(tc.function.name);
686
+ const allReadOnly = responseToolCalls.every(canRunInParallel);
687
+ if (allReadOnly && responseToolCalls.length > 1) {
688
+ // All tools are read-only — dispatch in parallel
689
+ const parallelChunkCallback = onToolOutputChunk
690
+ ? (id) => (chunk) => onToolOutputChunk(id, chunk)
691
+ : undefined;
692
+ const parallelResults = await Promise.allSettled(responseToolCalls.map(tc => executeToolCall(tc, toolRegistry, onToolCallStart, onToolCallEnd, checkPermission, options.lspManager, options.snapshotManager, options.sessionId, signal, options.hookEngine, mode, options.requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, parallelChunkCallback ? parallelChunkCallback(tc.id) : undefined, options.toolTimeouts, options.infraContext)));
693
+ for (let pi = 0; pi < responseToolCalls.length; pi++) {
694
+ const tc = responseToolCalls[pi];
695
+ const pResult = parallelResults[pi];
696
+ const pContent = pResult.status === 'fulfilled'
697
+ ? (pResult.value.isError ? `Error: ${pResult.value.error}` : pResult.value.output)
698
+ : `Error: ${pResult.reason}`;
699
+ messages.push({ role: 'tool', toolCallId: tc.id, name: tc.function.name, content: pContent });
700
+ }
701
+ // Skip sequential processing — jump directly to next LLM turn
702
+ continue;
703
+ }
704
+ // Process tool calls sequentially (order may matter for side effects)
705
+ for (const toolCall of responseToolCalls) {
706
+ // Check for cancellation between tool calls
707
+ if (signal?.aborted) {
708
+ interrupted = true;
709
+ break;
710
+ }
711
+ // G3: Enforce per-turn tool call limit to prevent runaway loops
712
+ turnToolCallCount++;
713
+ if (turnToolCallCount > MAX_TOOL_CALLS_PER_TURN) {
714
+ messages.push({
715
+ role: 'tool',
716
+ toolCallId: toolCall.id,
717
+ name: toolCall.function.name,
718
+ content: `[Tool limit reached: ${MAX_TOOL_CALLS_PER_TURN} tool calls in this turn. Summarizing progress and stopping to avoid runaway execution.]`,
719
+ });
720
+ break;
721
+ }
722
+ // G3: Count destructive operations at the session level
723
+ if (isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
724
+ sessionDestructiveOps++;
725
+ }
726
+ // G10: One-time kubectl RBAC pre-flight check — runs before the first kubectl call
727
+ // in this session. Stores the RBAC permissions summary in rbacPreamble so it can
728
+ // be injected into the first kubectl tool result (keeps conversation structure valid).
729
+ // Uses async execFile to avoid blocking the Node.js event loop (up to 5s call).
730
+ if (!kubectlRbacChecked && toolCall.function.name === 'kubectl') {
731
+ kubectlRbacChecked = true;
732
+ try {
733
+ const { stdout: rbacOut } = await _execFileAsync('kubectl', ['auth', 'can-i', '--list'], {
734
+ encoding: 'utf-8', timeout: 5000,
735
+ });
736
+ const truncated = rbacOut.length > 1500
737
+ ? `${rbacOut.slice(0, 1500)}\n...[truncated]`
738
+ : rbacOut;
739
+ rbacPreamble = `[kubectl RBAC context: permissions available in current context]\n${truncated}\n\n`;
740
+ }
741
+ catch { /* non-critical — RBAC check failure does not block kubectl */ }
742
+ }
743
+ // M6: Destructive action guard — inject warning into LLM context before executing
744
+ try {
745
+ const m6Input = JSON.parse(toolCall.function.arguments);
746
+ const destructiveWarning = isDestructiveAction(toolCall.function.name, m6Input);
747
+ if (destructiveWarning) {
748
+ messages.push({
749
+ role: 'tool',
750
+ toolCallId: toolCall.id + '-guard',
751
+ name: toolCall.function.name,
752
+ content: `[SAFETY] ${destructiveWarning}`,
753
+ });
754
+ }
755
+ }
756
+ catch { /* ignore parse errors */ }
757
+ // Build chunk callback that forwards tool output to the TUI in real-time
758
+ const chunkCallback = onToolOutputChunk
759
+ ? (chunk) => onToolOutputChunk(toolCall.id, chunk)
760
+ : undefined;
761
+ const result = await executeToolCall(toolCall, toolRegistry, onToolCallStart, onToolCallEnd, checkPermission, options.lspManager, options.snapshotManager, options.sessionId, signal, options.hookEngine, mode, options.requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, chunkCallback, options.toolTimeouts, options.infraContext);
762
+ // Append each tool result as a separate message so the LLM can
763
+ // match it to the corresponding tool_use block by toolCallId.
764
+ let toolContent = result.isError ? `Error: ${result.error}` : result.output;
765
+ // G10: Inject RBAC context preamble into the first kubectl result
766
+ if (rbacPreamble && toolCall.function.name === 'kubectl') {
767
+ toolContent = rbacPreamble + toolContent;
768
+ rbacPreamble = ''; // consume once — only injected into the first kubectl result
769
+ }
770
+ // Inject DevOps error classification hints to guide self-correction
771
+ if (result.isError && result.error) {
772
+ const hint = classifyDevOpsError(toolCall.function.name, result.error, options.nimbusInstructions);
773
+ if (hint) {
774
+ toolContent += `\n\n${hint}`;
775
+ // C4: Also show hint in TUI error output (not just LLM context)
776
+ result.output += `\n\n${hint}`;
777
+ // M2/M5: Auto-retry signal on credential expiry errors
778
+ // If the classified hint indicates a credential/auth problem, append
779
+ // a structured prompt so the agent knows to run auth-refresh, and
780
+ // set provider-specific env hints for the auth-refresh command.
781
+ const isCredentialError = hint.toLowerCase().includes('credential') ||
782
+ hint.toLowerCase().includes('expired') ||
783
+ hint.toLowerCase().includes('auth') ||
784
+ hint.toLowerCase().includes('login required');
785
+ if (isCredentialError && !credentialRetried.has(toolCall.id ?? toolCall.function.name)) {
786
+ credentialRetried.add(toolCall.id ?? toolCall.function.name);
787
+ // M5: Set provider-specific refresh hint env vars so auth-refresh
788
+ // can surface targeted guidance when invoked by the user.
789
+ const errorLower = (result.error ?? '').toLowerCase();
790
+ if (errorLower.includes('aws')) {
791
+ process.env.NIMBUS_AWS_REFRESH_HINT = '1';
792
+ }
793
+ if (errorLower.includes('gcp') || errorLower.includes('google')) {
794
+ process.env.NIMBUS_GCP_REFRESH_HINT = '1';
795
+ }
796
+ if (errorLower.includes('azure')) {
797
+ process.env.NIMBUS_AZURE_REFRESH_HINT = '1';
798
+ }
799
+ const refreshMsg = [
800
+ '[!!] Credential expired. Run: nimbus auth-refresh',
801
+ '[Nimbus] Credential error detected on tool: ' + toolCall.function.name,
802
+ 'Run "nimbus auth-refresh" to refresh cloud credentials, then retry.',
803
+ ].join('\n');
804
+ toolContent += '\n\n' + refreshMsg;
805
+ result.output += '\n\n' + refreshMsg;
806
+ }
807
+ }
808
+ else if (DEVOPS_TOOL_NAMES.has(toolCall.function.name)) {
809
+ // Unknown DevOps error — provide structured self-diagnosis steps
810
+ toolContent += [
811
+ '\n\n--- Self-Diagnosis Steps ---',
812
+ '1. Check tool is installed: `which terraform` / `kubectl version` / `helm version`',
813
+ '2. Check credentials: `aws sts get-caller-identity` / `gcloud auth list` / `az account show`',
814
+ '3. Check network connectivity to the cluster/cloud provider',
815
+ '4. Retry with verbose flag if available (e.g., TF_LOG=DEBUG, kubectl --v=6)',
816
+ '5. If the error persists, report the exact error message and the command that caused it.',
817
+ ].join('\n');
818
+ }
819
+ // M4: Track recurring errors and persist to NIMBUS.md after 3 occurrences
820
+ const m4Hint = classifyDevOpsError(toolCall.function.name, result.error ?? '', options.nimbusInstructions);
821
+ if (m4Hint) {
822
+ trackAndPersistError(toolCall.function.name, m4Hint, options.cwd ?? process.cwd());
823
+ }
824
+ }
825
+ // H5: Inject cost delta hint after successful infra operations
826
+ if (!result.isError) {
827
+ try {
828
+ const h5Input = JSON.parse(toolCall.function.arguments);
829
+ const costHint = extractCostHintFromToolOutput(toolCall.function.name, h5Input, result.output);
830
+ if (costHint) {
831
+ onText?.(`\n[cost] ${costHint}\n`);
832
+ }
833
+ }
834
+ catch { /* ignore parse errors */ }
835
+ }
836
+ // L6: Auto-generate runbook after terraform apply success
837
+ if (!result.isError && toolCall.function.name === 'terraform') {
838
+ try {
839
+ const l6Input = JSON.parse(toolCall.function.arguments);
840
+ if (String(l6Input.action) === 'apply') {
841
+ const l6Match = result.output.match(/Resources:\s*(\d+) added/);
842
+ if (l6Match && parseInt(l6Match[1] ?? '0', 10) > 0) {
843
+ const { join: _l6Join } = require('node:path');
844
+ const { homedir: _l6Homedir } = require('node:os');
845
+ const { mkdirSync: _l6MkdirSync, writeFileSync: _l6WriteFileSync } = require('node:fs');
846
+ const runbookDir = _l6Join(_l6Homedir(), '.nimbus', 'runbooks');
847
+ _l6MkdirSync(runbookDir, { recursive: true });
848
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
849
+ const runbookPath = _l6Join(runbookDir, `terraform-apply-${ts}.md`);
850
+ const runbookContent = [
851
+ '# Terraform Apply Runbook',
852
+ '',
853
+ `Date: ${new Date().toLocaleString()}`,
854
+ '',
855
+ 'Apply output:',
856
+ '```',
857
+ result.output.slice(0, 2000),
858
+ '```',
859
+ '',
860
+ '## Rollback',
861
+ '',
862
+ 'To rollback, run `terraform destroy` or restore from a previous state.',
863
+ ].join('\n');
864
+ _l6WriteFileSync(runbookPath, runbookContent, 'utf-8');
865
+ options.onText?.(`\n[runbook] Saved to ${runbookPath}\n`);
866
+ }
867
+ }
868
+ }
869
+ catch { /* non-critical */ }
870
+ }
871
+ // GAP-25: Structured audit trail for destructive operations
872
+ if (!result.isError && isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
873
+ try {
874
+ const { appendFileSync, mkdirSync } = await import('node:fs');
875
+ const { homedir } = await import('node:os');
876
+ const { join } = await import('node:path');
877
+ const auditDir = join(homedir(), '.nimbus');
878
+ mkdirSync(auditDir, { recursive: true });
879
+ const event = JSON.stringify({
880
+ type: 'infra-change',
881
+ tool: toolCall.function.name,
882
+ action: JSON.parse(toolCall.function.arguments).action,
883
+ sessionId: options.sessionId ?? 'unknown',
884
+ cwd: options.cwd ?? process.cwd(),
885
+ timestamp: new Date().toISOString(),
886
+ });
887
+ appendFileSync(join(auditDir, 'audit.jsonl'), event + '\n', 'utf-8');
888
+ }
889
+ catch { /* audit logging is non-critical */ }
890
+ }
891
+ // G3: Append a warning when session-level destructive op threshold is reached
892
+ if (sessionDestructiveOps >= MAX_DESTRUCTIVE_OPS_PER_SESSION) {
893
+ toolContent += `\n\n[Warning: ${sessionDestructiveOps} destructive operations executed in this session. Review changes carefully.]`;
894
+ }
895
+ // Cache terraform plan output so a subsequent apply can reference it.
896
+ // Also track planned workdirs (G8) and warn on unplanned applies.
897
+ if (toolCall.function.name === 'terraform' && !result.isError) {
898
+ try {
899
+ const tfArgs = JSON.parse(toolCall.function.arguments);
900
+ if (tfArgs.action === 'plan' && tfArgs.workdir) {
901
+ cacheTerraformPlan(String(tfArgs.workdir), result.output);
902
+ // G8: Track that a plan was run for this workdir in this session
903
+ terraformPlannedWorkdirs.add(String(tfArgs.workdir));
904
+ }
905
+ // G8: Warn if apply ran without a prior plan in this session
906
+ if (tfArgs.action === 'apply' && tfArgs.workdir && !terraformPlannedWorkdirs.has(String(tfArgs.workdir))) {
907
+ toolContent = `[Note: terraform apply ran without a prior terraform plan in this session for ${String(tfArgs.workdir)}. Always run terraform plan first to review changes before applying.]\n\n${toolContent}`;
908
+ }
909
+ // Inject cached plan into apply context for the LLM
910
+ if (tfArgs.action === 'apply' && tfArgs.workdir) {
911
+ const cached = getCachedTerraformPlan(String(tfArgs.workdir));
912
+ if (cached) {
913
+ toolContent = `[Apply succeeded. This was the plan that was applied:]\n${cached.slice(0, 3000)}\n\n[Apply output:]\n${toolContent}`;
914
+ }
915
+ }
916
+ }
917
+ catch { /* ignore parse errors */ }
918
+ }
919
+ // GAP-11: trigger FileDiff UI after terraform plan shows resource changes
920
+ if (toolCall.function.name === 'terraform' && !result.isError && options.requestFileDiff) {
921
+ try {
922
+ const tfArgs11 = JSON.parse(toolCall.function.arguments);
923
+ if (tfArgs11.action === 'plan') {
924
+ const { parseTerraformPlanOutput, buildFileDiffBatchFromPlan } = await import('./deploy-preview');
925
+ const changes = parseTerraformPlanOutput(toolContent);
926
+ if (changes.length > 0) {
927
+ const batchFiles = buildFileDiffBatchFromPlan({ changes });
928
+ for (const file of batchFiles) {
929
+ const decision = await options.requestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
930
+ if (decision === 'reject-all')
931
+ break;
932
+ }
933
+ }
934
+ }
935
+ }
936
+ catch { /* non-critical — FileDiff UI not always available */ }
937
+ }
938
+ // GAP-18: auto-validate terraform files after write/edit tool calls
939
+ if (['write_file', 'edit_file', 'multi_edit'].includes(toolCall.function.name) && !result.isError) {
940
+ const gap18Input = JSON.parse(toolCall.function.arguments);
941
+ const gap18FilePath = gap18Input.path ?? gap18Input.file_path ?? '';
942
+ if (gap18FilePath.endsWith('.tf')) {
943
+ try {
944
+ // Use async exec to avoid blocking the event loop (up to 10s for terraform validate)
945
+ const { stdout: validateOut } = await _execAsync('terraform validate -json 2>/dev/null', {
946
+ cwd: options.cwd ?? process.cwd(),
947
+ encoding: 'utf-8',
948
+ timeout: 10_000,
949
+ });
950
+ const parsed = JSON.parse(validateOut);
951
+ if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
952
+ const errors = parsed.diagnostics
953
+ .filter(d => d.severity === 'error')
954
+ .map(d => ` ${d.summary}: ${d.detail}`)
955
+ .join('\n');
956
+ toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
957
+ }
958
+ }
959
+ catch { /* terraform not available or not in tf project — ignore */ }
960
+ }
961
+ }
962
+ // Truncate excessively large tool outputs to prevent context overflow
963
+ if (toolContent.length > MAX_TOOL_OUTPUT_CHARS) {
964
+ let head;
965
+ let tail;
966
+ let omitted;
967
+ const lines = toolContent.split('\n');
968
+ // C3: Smart truncation for terraform plan — preserve all diff lines
969
+ const isTerraformPlan = toolCall.function.name === 'terraform' && (() => {
970
+ try {
971
+ const tfArgs = JSON.parse(toolCall.function.arguments);
972
+ return tfArgs.action === 'plan';
973
+ }
974
+ catch {
975
+ return false;
976
+ }
977
+ })();
978
+ if (isTerraformPlan) {
979
+ // Keep all diff lines (create/update/destroy/replace) and the plan summary
980
+ const diffLines = [];
981
+ const contextLines = [];
982
+ for (const line of lines) {
983
+ const trimmed = line.trimStart();
984
+ const isDiffLine = trimmed.startsWith('+') || trimmed.startsWith('-') ||
985
+ trimmed.startsWith('~') || trimmed.startsWith('!') ||
986
+ line.includes('will be created') || line.includes('will be destroyed') ||
987
+ line.includes('will be updated') || line.includes('will be replaced') ||
988
+ line.includes('Plan:') || line.includes('No changes') ||
989
+ line.includes('Error:') || line.includes('Warning:');
990
+ if (isDiffLine) {
991
+ diffLines.push(line);
992
+ }
993
+ else {
994
+ contextLines.push(line);
995
+ }
996
+ }
997
+ // Allow up to 500 diff lines + first 50 context lines
998
+ const keptDiff = diffLines.slice(0, 500);
999
+ const keptCtx = contextLines.slice(0, 50);
1000
+ omitted = Math.max(0, lines.length - keptDiff.length - keptCtx.length);
1001
+ head = [...keptCtx, ...keptDiff].join('\n');
1002
+ tail = '';
1003
+ }
1004
+ else {
1005
+ const headLines = 100, tailLines = 20;
1006
+ head = lines.slice(0, headLines).join('\n');
1007
+ tail = lines.slice(-tailLines).join('\n');
1008
+ omitted = Math.max(0, lines.length - headLines - tailLines);
1009
+ }
1010
+ // Save full output to disk for reference
1011
+ try {
1012
+ const { mkdirSync: _mkdirSync, writeFileSync: _writeFileSync } = await import('node:fs');
1013
+ const { homedir: _homedir } = await import('node:os');
1014
+ const outDir = join(_homedir(), '.nimbus', 'tool-outputs');
1015
+ _mkdirSync(outDir, { recursive: true });
1016
+ const outFile = join(outDir, `${Date.now()}-${toolCall.function.name}.log`);
1017
+ _writeFileSync(outFile, toolContent, 'utf-8');
1018
+ toolContent = omitted > 0
1019
+ ? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — full output saved to ' + outFile + '] ...\n\n' + tail : '\n\n... [full output saved to ' + outFile + ']'}`
1020
+ : `${head}${tail ? '\n\n' + tail : ''}`;
1021
+ }
1022
+ catch {
1023
+ toolContent = omitted > 0
1024
+ ? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — output too large for context] ...\n\n' + tail : '\n\n... [' + omitted + ' lines omitted]'}`
1025
+ : `${head}${tail ? '\n\n' + tail : ''}`;
1026
+ }
1027
+ }
1028
+ messages.push({
1029
+ role: 'tool',
1030
+ toolCallId: toolCall.id,
1031
+ name: toolCall.function.name,
1032
+ content: toolContent,
1033
+ });
1034
+ }
1035
+ // If we broke out of the tool-call loop due to cancellation, exit
1036
+ // the main loop as well.
1037
+ if (interrupted) {
1038
+ break;
1039
+ }
1040
+ // -----------------------------------------------------------------
1041
+ // Auto-compact check
1042
+ // -----------------------------------------------------------------
1043
+ // After tool results are appended, check whether the conversation
1044
+ // has grown past the context window threshold. If so, summarize
1045
+ // older messages to free up space for future turns.
1046
+ if (options.contextManager) {
1047
+ const toolTokens = llmTools.reduce((sum, t) => sum + Math.ceil(JSON.stringify(t).length / 4), 0);
1048
+ if (options.contextManager.shouldCompact(systemPrompt, messages, toolTokens)) {
1049
+ try {
1050
+ const compactResult = await runCompaction(messages, options.contextManager, {
1051
+ router,
1052
+ ...(options.infraContext ? { infraContext: options.infraContext } : {}),
1053
+ });
1054
+ // Replace messages with the compacted version
1055
+ messages.length = 0;
1056
+ messages.push(...compactResult.messages);
1057
+ // Clear the token cache after compaction — old message entries are no longer valid
1058
+ options.contextManager.clearTokenCache();
1059
+ if (options.onCompact) {
1060
+ options.onCompact(compactResult.result);
1061
+ }
1062
+ }
1063
+ catch (compactErr) {
1064
+ // Compaction failed — notify user visibly and continue with original messages
1065
+ const compactErrMsg = compactErr instanceof Error ? compactErr.message : String(compactErr);
1066
+ if (onText) {
1067
+ onText(`\n[Warning: Auto-compaction failed: ${compactErrMsg}. Context may exceed budget on the next turn.]\n`);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ }
1073
+ catch (error) {
1074
+ // LLM API error — report to the caller and break
1075
+ const msg = error instanceof Error ? error.message : String(error);
1076
+ // G24: Network errors already printed via onText above — skip duplicate output
1077
+ const isNetworkErr = (error instanceof Error) && error._nimbusNetworkError;
1078
+ if (!isNetworkErr && onText) {
1079
+ onText(`\n[Error: ${msg}]\n`);
1080
+ }
1081
+ messages.push({
1082
+ role: 'assistant',
1083
+ content: isNetworkErr ? msg : `I encountered an error: ${msg}`,
1084
+ });
1085
+ break;
1086
+ }
1087
+ }
1088
+ // -----------------------------------------------------------------------
1089
+ // 4. Post-loop bookkeeping
1090
+ // -----------------------------------------------------------------------
1091
+ if (turns >= maxTurns && !interrupted) {
1092
+ if (onText) {
1093
+ onText(`\n[Agent reached maximum turns limit (${maxTurns}). Stopping.]\n`);
1094
+ }
1095
+ }
1096
+ // GAP-19: Session summary after multi-step deploy
1097
+ if (options.mode === 'deploy' && options.onText) {
1098
+ // Collect tool calls from messages
1099
+ const allToolCalls = [];
1100
+ for (const msg of messages) {
1101
+ if (msg.role === 'assistant' && Array.isArray(msg.toolCalls)) {
1102
+ for (const tc of msg.toolCalls) {
1103
+ try {
1104
+ allToolCalls.push({ name: tc.function.name, input: JSON.parse(tc.function.arguments) });
1105
+ }
1106
+ catch { /* ignore */ }
1107
+ }
1108
+ }
1109
+ }
1110
+ if (allToolCalls.length > 3) {
1111
+ const terraform = allToolCalls.filter(c => c.name === 'terraform');
1112
+ const kubectl = allToolCalls.filter(c => c.name === 'kubectl');
1113
+ const helm = allToolCalls.filter(c => c.name === 'helm');
1114
+ const summaryLines = ['---', '**Session Summary**'];
1115
+ if (terraform.length)
1116
+ summaryLines.push(`• Terraform: ${terraform.map(c => String(c.input.action ?? '')).join(', ')}`);
1117
+ if (kubectl.length)
1118
+ summaryLines.push(`• Kubectl: ${kubectl.map(c => String(c.input.action ?? '')).join(', ')}`);
1119
+ if (helm.length)
1120
+ summaryLines.push(`• Helm: ${helm.map(c => String(c.input.action ?? '')).join(', ')}`);
1121
+ if (summaryLines.length > 2) {
1122
+ options.onText('\n\n' + summaryLines.join('\n'));
1123
+ }
1124
+ }
1125
+ }
1126
+ return {
1127
+ messages,
1128
+ turns,
1129
+ interrupted,
1130
+ usage: totalUsage,
1131
+ totalCost,
1132
+ };
1133
+ }
1134
+ // ---------------------------------------------------------------------------
1135
+ // Tool Execution
1136
+ // ---------------------------------------------------------------------------
1137
+ /** Tools that modify files and should trigger LSP diagnostics. */
1138
+ const FILE_EDITING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
1139
+ /** Tools that mutate files and may require a pre-approval diff. */
1140
+ const FILE_MUTATING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
1141
+ /**
1142
+ * Generate a simple unified diff between two strings.
1143
+ * Suitable for display; uses a greedy line-by-line approach.
1144
+ */
1145
+ function generateUnifiedDiff(filename, before, after) {
1146
+ const beforeLines = before.split('\n');
1147
+ const afterLines = after.split('\n');
1148
+ const lines = [`--- a/${filename}`, `+++ b/${filename}`];
1149
+ let i = 0;
1150
+ let j = 0;
1151
+ while (i < beforeLines.length || j < afterLines.length) {
1152
+ if (beforeLines[i] === afterLines[j]) {
1153
+ i++;
1154
+ j++;
1155
+ continue;
1156
+ }
1157
+ const hunkBefore = [];
1158
+ const hunkAfter = [];
1159
+ const start = i;
1160
+ while (i < beforeLines.length && beforeLines[i] !== afterLines[j]) {
1161
+ hunkBefore.push(beforeLines[i++]);
1162
+ }
1163
+ while (j < afterLines.length &&
1164
+ (i >= beforeLines.length || beforeLines[i] !== afterLines[j])) {
1165
+ hunkAfter.push(afterLines[j++]);
1166
+ }
1167
+ lines.push(`@@ -${start + 1},${hunkBefore.length} +${start + 1},${hunkAfter.length} @@`);
1168
+ hunkBefore.forEach(l => lines.push(`-${l}`));
1169
+ hunkAfter.forEach(l => lines.push(`+${l}`));
1170
+ }
1171
+ return lines.join('\n');
1172
+ }
1173
+ /**
1174
+ * Compute a proposed diff for a file-mutating tool call without writing to disk.
1175
+ * Returns the unified diff string, or null if it cannot be computed.
1176
+ */
1177
+ async function computeProposedDiff(toolName, args) {
1178
+ try {
1179
+ const { readFile } = await import('node:fs/promises');
1180
+ const path = args.path;
1181
+ if (!path)
1182
+ return null;
1183
+ const currentContent = await readFile(path, 'utf-8').catch(() => '');
1184
+ let proposed = currentContent;
1185
+ if (toolName === 'edit_file') {
1186
+ proposed = currentContent.replace(args.old_string, args.new_string);
1187
+ }
1188
+ else if (toolName === 'multi_edit') {
1189
+ const edits = args.edits;
1190
+ if (Array.isArray(edits)) {
1191
+ for (const e of edits) {
1192
+ proposed = proposed.replace(e.old_string, e.new_string);
1193
+ }
1194
+ }
1195
+ }
1196
+ else if (toolName === 'write_file') {
1197
+ proposed = args.content;
1198
+ }
1199
+ if (proposed === currentContent)
1200
+ return null; // no change
1201
+ return generateUnifiedDiff(path, currentContent, proposed);
1202
+ }
1203
+ catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Extract the file path from a tool call's parsed arguments.
1209
+ *
1210
+ * File-editing tools all have a `path` parameter that identifies
1211
+ * the target file. Returns `null` for non-file tools.
1212
+ */
1213
+ function extractFilePath(toolName, input) {
1214
+ if (!FILE_EDITING_TOOLS.has(toolName)) {
1215
+ return null;
1216
+ }
1217
+ if (input && typeof input === 'object' && 'path' in input) {
1218
+ return input.path;
1219
+ }
1220
+ return null;
1221
+ }
1222
+ /**
1223
+ * Execute a single tool call.
1224
+ *
1225
+ * Handles:
1226
+ * - Looking up the tool in the registry.
1227
+ * - Parsing the JSON arguments string from the LLM response.
1228
+ * - Validating input against the Zod schema.
1229
+ * - Checking permissions via the caller-supplied callback.
1230
+ * - Invoking the tool and returning the result.
1231
+ * - Notifying start/end callbacks.
1232
+ * - Querying the LSP for diagnostics after file edits.
1233
+ *
1234
+ * @param toolCall - The raw tool call from the LLM response.
1235
+ * @param registry - The tool registry to look up the tool definition.
1236
+ * @param onStart - Optional callback fired before execution.
1237
+ * @param onEnd - Optional callback fired after execution (or error).
1238
+ * @param checkPermission - Optional permission gate.
1239
+ * @param lspManager - Optional LSP manager for post-edit diagnostics.
1240
+ * @returns The tool result (always succeeds; errors are captured inside the result).
1241
+ */
1242
+ async function executeToolCall(toolCall, registry, onStart, onEnd, checkPermission, lspManager, snapshotManager, sessionId, signal, hookEngine, mode, requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, onChunk, toolTimeouts, infraContext) {
1243
+ const toolName = toolCall.function.name;
1244
+ // Parse the JSON arguments string from the LLM
1245
+ let parsedArgs;
1246
+ try {
1247
+ parsedArgs = JSON.parse(toolCall.function.arguments);
1248
+ }
1249
+ catch {
1250
+ const result = {
1251
+ output: '',
1252
+ error: `Tool '${toolName}' received malformed JSON arguments — please retry the tool call with valid JSON. Received: ${toolCall.function.arguments.slice(0, 200)}`,
1253
+ isError: true,
1254
+ };
1255
+ return result;
1256
+ }
1257
+ const callInfo = {
1258
+ id: toolCall.id,
1259
+ name: toolName,
1260
+ input: parsedArgs,
1261
+ startTime: Date.now(),
1262
+ };
1263
+ // Look up the tool definition
1264
+ const tool = registry.get(toolName);
1265
+ if (!tool) {
1266
+ const result = {
1267
+ output: '',
1268
+ error: `Unknown tool: ${toolName}`,
1269
+ isError: true,
1270
+ };
1271
+ if (onEnd) {
1272
+ onEnd(callInfo, result);
1273
+ }
1274
+ return result;
1275
+ }
1276
+ // Notify start
1277
+ if (onStart) {
1278
+ onStart(callInfo);
1279
+ }
1280
+ // Build shared hook context for PreToolUse and PostToolUse
1281
+ const hookContext = {
1282
+ tool: toolName,
1283
+ input: parsedArgs && typeof parsedArgs === 'object' ? parsedArgs : {},
1284
+ sessionId: sessionId ?? 'default',
1285
+ agent: mode ?? 'build',
1286
+ timestamp: new Date().toISOString(),
1287
+ };
1288
+ // PreToolUse hooks — may block the tool call
1289
+ if (hookEngine) {
1290
+ const preResult = await runPreToolHooks(hookEngine, hookContext);
1291
+ if (!preResult.allowed) {
1292
+ const result = {
1293
+ output: '',
1294
+ error: `Tool '${toolName}' blocked by hook: ${preResult.message ?? 'no reason given'}`,
1295
+ isError: true,
1296
+ };
1297
+ if (onEnd) {
1298
+ onEnd(callInfo, result);
1299
+ }
1300
+ return result;
1301
+ }
1302
+ }
1303
+ // Permission check
1304
+ if (checkPermission) {
1305
+ const decision = await checkPermission(tool, parsedArgs);
1306
+ if (decision === 'deny' || decision === 'block') {
1307
+ const result = {
1308
+ output: '',
1309
+ error: decision === 'block'
1310
+ ? `Tool '${toolName}' is blocked by permission policy.`
1311
+ : `User denied permission for tool '${toolName}'.`,
1312
+ isError: true,
1313
+ };
1314
+ if (onEnd) {
1315
+ onEnd(callInfo, result);
1316
+ }
1317
+ return result;
1318
+ }
1319
+ }
1320
+ // B1: Pre-approval diff — show proposed change before writing files
1321
+ if (FILE_MUTATING_TOOLS.has(toolName) &&
1322
+ requestFileDiff &&
1323
+ !(skipRemainingDiffPrompts?.value)) {
1324
+ // Auto-reject if 'reject-all' was previously chosen
1325
+ if (rejectRemainingDiffPrompts?.value) {
1326
+ const rejResult = {
1327
+ output: 'User rejected this change (reject-all).',
1328
+ error: undefined,
1329
+ isError: false,
1330
+ };
1331
+ if (onEnd)
1332
+ onEnd(callInfo, rejResult);
1333
+ return rejResult;
1334
+ }
1335
+ const diff = await computeProposedDiff(toolName, parsedArgs);
1336
+ if (diff) {
1337
+ const targetPath = parsedArgs.path ?? '(file)';
1338
+ const decision = await requestFileDiff(targetPath, toolName, diff);
1339
+ if (decision === 'reject') {
1340
+ const rejResult = {
1341
+ output: 'User rejected this change.',
1342
+ error: undefined,
1343
+ isError: false,
1344
+ };
1345
+ if (onEnd)
1346
+ onEnd(callInfo, rejResult);
1347
+ return rejResult;
1348
+ }
1349
+ if (decision === 'reject-all') {
1350
+ if (rejectRemainingDiffPrompts) {
1351
+ rejectRemainingDiffPrompts.value = true;
1352
+ }
1353
+ const rejResult = {
1354
+ output: 'User rejected this change (reject-all).',
1355
+ error: undefined,
1356
+ isError: false,
1357
+ };
1358
+ if (onEnd)
1359
+ onEnd(callInfo, rejResult);
1360
+ return rejResult;
1361
+ }
1362
+ if (decision === 'apply-all' && skipRemainingDiffPrompts) {
1363
+ skipRemainingDiffPrompts.value = true;
1364
+ }
1365
+ }
1366
+ }
1367
+ // Capture snapshot before file-modifying tools for undo/redo support
1368
+ if (snapshotManager &&
1369
+ SnapshotManager.shouldSnapshot(toolName, parsedArgs)) {
1370
+ try {
1371
+ await snapshotManager.captureSnapshot({
1372
+ sessionId: sessionId || 'default',
1373
+ messageId: toolCall.id,
1374
+ toolCallId: toolCall.id,
1375
+ description: `${toolName}: ${extractFilePath(toolName, parsedArgs) || '(bash command)'}`,
1376
+ });
1377
+ }
1378
+ catch {
1379
+ // Snapshot failure should never block the tool call
1380
+ }
1381
+ }
1382
+ // Validate input against the tool's Zod schema and execute
1383
+ let result;
1384
+ try {
1385
+ const validatedInput = tool.inputSchema.parse(parsedArgs);
1386
+ // Thread AbortSignal into bash tool for Ctrl+C child process killing
1387
+ if (signal && toolName === 'bash' && validatedInput && typeof validatedInput === 'object') {
1388
+ validatedInput._signal = signal;
1389
+ }
1390
+ // GAP-20: Build tool execute context, including per-tool timeout from toolTimeouts map
1391
+ // C2: Also pass infraContext from session so tools can use it as fallback
1392
+ const toolCtx = onChunk || toolTimeouts?.[toolName] || infraContext
1393
+ ? {
1394
+ ...(onChunk ? { onProgress: onChunk } : {}),
1395
+ ...(toolTimeouts?.[toolName] !== undefined ? { timeout: toolTimeouts[toolName] } : {}),
1396
+ ...(infraContext ? { infraContext } : {}),
1397
+ }
1398
+ : undefined;
1399
+ // C2: Write infra checkpoint before mutating terraform/helm operations
1400
+ if (toolName === 'terraform' || toolName === 'helm') {
1401
+ const _cpArgs = parsedArgs && typeof parsedArgs === 'object'
1402
+ ? parsedArgs
1403
+ : {};
1404
+ const _cpAction = String(_cpArgs.action ?? '');
1405
+ const _cpNeedCheckpoint = (toolName === 'terraform' && _cpAction === 'apply') ||
1406
+ (toolName === 'helm' && ['install', 'upgrade', 'rollback'].includes(_cpAction));
1407
+ if (_cpNeedCheckpoint) {
1408
+ writeInfraCheckpoint(toolName, _cpAction, _cpArgs);
1409
+ }
1410
+ }
1411
+ result = await tool.execute(validatedInput, toolCtx);
1412
+ }
1413
+ catch (error) {
1414
+ result = {
1415
+ output: '',
1416
+ error: formatToolInputError(toolName, error),
1417
+ isError: true,
1418
+ };
1419
+ }
1420
+ // -----------------------------------------------------------------------
1421
+ // LSP diagnostics injection
1422
+ // -----------------------------------------------------------------------
1423
+ // After a successful file edit, notify the language server and collect
1424
+ // any diagnostics (type errors, lint issues). If errors exist they are
1425
+ // appended to the tool output so the LLM sees them on its next turn
1426
+ // and can self-correct.
1427
+ if (lspManager && !result.isError) {
1428
+ const filePath = extractFilePath(toolName, parsedArgs);
1429
+ if (filePath) {
1430
+ try {
1431
+ await lspManager.touchFile(filePath);
1432
+ const diagnostics = await lspManager.getDiagnostics(filePath);
1433
+ if (diagnostics.length > 0) {
1434
+ const formatted = lspManager.formatDiagnosticsForAgent(diagnostics);
1435
+ if (formatted) {
1436
+ result = {
1437
+ ...result,
1438
+ output: result.output ? `${result.output}\n\n${formatted}` : formatted,
1439
+ };
1440
+ }
1441
+ }
1442
+ }
1443
+ catch (lspErr) {
1444
+ // LSP errors should never block the agent loop.
1445
+ // Append a note to the tool result so the LLM (and user) can see it.
1446
+ const lspErrMsg = lspErr instanceof Error ? lspErr.message : String(lspErr);
1447
+ result = {
1448
+ ...result,
1449
+ output: result.output
1450
+ ? `${result.output}\n\n[Note: LSP diagnostics unavailable: ${lspErrMsg}]`
1451
+ : `[Note: LSP diagnostics unavailable: ${lspErrMsg}]`,
1452
+ };
1453
+ }
1454
+ }
1455
+ }
1456
+ // Gap 12: Mask secrets in tool output before forwarding to callbacks/history
1457
+ if (!result.isError && result.output) {
1458
+ result = { ...result, output: maskSecrets(result.output) };
1459
+ }
1460
+ // PostToolUse hooks — fire-and-forget (audit, auto-format, etc.)
1461
+ if (hookEngine) {
1462
+ await runPostToolHooks(hookEngine, {
1463
+ ...hookContext,
1464
+ result: {
1465
+ output: result.isError ? (result.error ?? '') : result.output,
1466
+ isError: result.isError,
1467
+ },
1468
+ });
1469
+ }
1470
+ // Notify end
1471
+ if (onEnd) {
1472
+ onEnd(callInfo, result);
1473
+ }
1474
+ return result;
1475
+ }
1476
+ // ---------------------------------------------------------------------------
1477
+ // Mode-Based Tool Filtering
1478
+ // ---------------------------------------------------------------------------
1479
+ /**
1480
+ * Set of tool names allowed in `plan` mode.
1481
+ *
1482
+ * Plan mode is strictly read-only: the agent can inspect files, search
1483
+ * the codebase, read tasks, estimate costs, and detect drift -- but it
1484
+ * cannot write files, run commands, or mutate infrastructure.
1485
+ */
1486
+ const PLAN_MODE_TOOLS = new Set([
1487
+ 'read_file',
1488
+ 'glob',
1489
+ 'grep',
1490
+ 'list_dir',
1491
+ 'webfetch',
1492
+ 'todo_read',
1493
+ 'todo_write',
1494
+ 'task',
1495
+ 'cost_estimate',
1496
+ 'drift_detect',
1497
+ 'cloud_discover',
1498
+ ]);
1499
+ /**
1500
+ * Set of tool names blocked in `build` mode.
1501
+ *
1502
+ * Build mode allows reads and writes (file edits, code generation) but
1503
+ * blocks infrastructure-mutating operations that could affect live
1504
+ * environments. The permission engine provides fine-grained control on
1505
+ * top of this coarse filter.
1506
+ */
1507
+ const BUILD_MODE_BLOCKED_TOOLS = new Set(['terraform', 'kubectl', 'helm']);
1508
+ /**
1509
+ * Filter tools based on the current agent mode.
1510
+ *
1511
+ * - **plan**: Only read-only tools + cost/drift analysis.
1512
+ * - **build**: All tools except infrastructure mutation commands.
1513
+ * - **deploy**: All tools are available.
1514
+ *
1515
+ * @param allTools - Every tool registered in the system.
1516
+ * @param mode - The active agent mode.
1517
+ * @returns The subset of tools available in the given mode.
1518
+ */
1519
+ export function getToolsForMode(allTools, mode) {
1520
+ switch (mode) {
1521
+ case 'plan':
1522
+ return allTools.filter(t => PLAN_MODE_TOOLS.has(t.name));
1523
+ case 'build':
1524
+ return allTools.filter(t => !BUILD_MODE_BLOCKED_TOOLS.has(t.name));
1525
+ case 'deploy':
1526
+ // All tools available
1527
+ return allTools;
1528
+ default: {
1529
+ // Exhaustive check -- if a new mode is added this becomes a compile
1530
+ // error (assuming AgentMode is a union type).
1531
+ const _exhaustive = mode;
1532
+ return allTools;
1533
+ }
1534
+ }
1535
+ }