@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
package/src/agent/loop.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * @module agent/loop
18
18
  */
19
19
 
20
+ import { join } from 'node:path';
20
21
  import type { LLMRouter } from '../llm/router';
21
22
  import type {
22
23
  LLMMessage,
@@ -27,6 +28,7 @@ import type {
27
28
  import {
28
29
  toOpenAITool,
29
30
  type ToolDefinition,
31
+ type ToolExecuteContext,
30
32
  type ToolResult,
31
33
  type ToolRegistry,
32
34
  } from '../tools/schemas/types';
@@ -36,6 +38,314 @@ import { runCompaction } from './compaction-agent';
36
38
  import type { LSPManager } from '../lsp/manager';
37
39
  import { SnapshotManager } from '../snapshots/manager';
38
40
  import { calculateCost } from '../llm/cost-calculator';
41
+ import {
42
+ HookEngine,
43
+ runPreToolHooks,
44
+ runPostToolHooks,
45
+ type HookContext,
46
+ } from '../hooks/engine';
47
+ import { maskSecrets } from '../audit/security-scanner';
48
+ import { classifyTaskComplexity, routeModel } from '../llm/router';
49
+ import { mkdirSync as _cpMkdirSync, writeFileSync as _cpWriteFileSync } from 'node:fs';
50
+ import { homedir as _cpHomedir } from 'node:os';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // C2: Infra state checkpoint helper
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Write a checkpoint JSON file to ~/.nimbus/infra-checkpoints/<timestamp>.json
58
+ * before a mutating terraform or helm operation. Non-blocking — errors are swallowed.
59
+ */
60
+ function writeInfraCheckpoint(tool: string, action: string, input: Record<string, unknown>): void {
61
+ try {
62
+ const checkpointsDir = join(_cpHomedir(), '.nimbus', 'infra-checkpoints');
63
+ _cpMkdirSync(checkpointsDir, { recursive: true });
64
+ // Sanitize: remove any field that looks like a secret
65
+ const sanitized: Record<string, unknown> = {};
66
+ for (const [k, v] of Object.entries(input)) {
67
+ const lower = k.toLowerCase();
68
+ if (lower.includes('secret') || lower.includes('password') || lower.includes('token') || lower.includes('key')) {
69
+ sanitized[k] = '[redacted]';
70
+ } else {
71
+ sanitized[k] = v;
72
+ }
73
+ }
74
+ const timestamp = new Date().toISOString();
75
+ const checkpoint = {
76
+ timestamp,
77
+ tool,
78
+ action,
79
+ input: sanitized,
80
+ cwd: process.cwd(),
81
+ workdir: (input.workdir as string | undefined) ?? undefined,
82
+ };
83
+ const fileName = timestamp.replace(/[:.]/g, '-') + '.json';
84
+ _cpWriteFileSync(
85
+ join(checkpointsDir, fileName),
86
+ JSON.stringify(checkpoint, null, 2),
87
+ 'utf-8'
88
+ );
89
+ } catch { /* non-critical */ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Module-level compiled regex constants for classifyDevOpsError (PERF-1d).
98
+ // Hoisted here so they compile once at module load rather than per-call.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const _RE_CREDENTIAL_EXPIRY_AWS = /ExpiredTokenException|TokenExpiredException|token.*has.*expired/i;
102
+ const _RE_CREDENTIAL_EXPIRY_GCP = /credentials.*expired|Application Default Credentials.*expired|re-authenticate/i;
103
+ const _RE_CREDENTIAL_EXPIRY_AZURE = /AADSTS70008|InteractionRequired|credential.*expired/i;
104
+ const _RE_CMD_NOT_FOUND = /command not found|not found|no such file or directory/i;
105
+
106
+ /**
107
+ * Classify a DevOps tool error and return an actionable hint for the LLM.
108
+ * Returns null for unrecognized errors so we don't pollute the context.
109
+ */
110
+ function classifyDevOpsError(toolName: string, errorOutput: string, nimbusInstructions?: string): string | null {
111
+ const e = errorOutput.toLowerCase();
112
+
113
+ // GAP-13: Credential expiry patterns — must come first for fast matching
114
+ const CREDENTIAL_EXPIRY = [
115
+ { re: _RE_CREDENTIAL_EXPIRY_AWS, provider: 'aws' },
116
+ { re: _RE_CREDENTIAL_EXPIRY_GCP, provider: 'gcp' },
117
+ { re: _RE_CREDENTIAL_EXPIRY_AZURE, provider: 'azure' },
118
+ ];
119
+ for (const { re, provider } of CREDENTIAL_EXPIRY) {
120
+ if (re.test(errorOutput)) {
121
+ return `Your ${provider.toUpperCase()} credentials have expired.\n\nRun: \`nimbus auth-refresh --provider ${provider}\` to refresh them.`;
122
+ }
123
+ }
124
+
125
+ // G3: "command not found" — provide installation hints for DevOps CLIs
126
+ const INSTALL_HINTS: Record<string, string> = {
127
+ terraform: 'brew install terraform OR https://developer.hashicorp.com/terraform/install',
128
+ kubectl: 'brew install kubectl OR https://kubernetes.io/docs/tasks/tools/',
129
+ helm: 'brew install helm OR https://helm.sh/docs/intro/install/',
130
+ docker: 'brew install --cask docker OR https://docs.docker.com/get-docker/',
131
+ aws: 'brew install awscli OR pip install awscli',
132
+ gcloud: 'brew install --cask google-cloud-sdk',
133
+ az: 'brew install azure-cli',
134
+ };
135
+ if (_RE_CMD_NOT_FOUND.test(errorOutput)) {
136
+ for (const [cmd, hint] of Object.entries(INSTALL_HINTS)) {
137
+ if (toolName.includes(cmd) || e.includes(`'${cmd}'`) || e.includes(`"${cmd}"`)) {
138
+ return `\`${cmd}\` is not installed.\n\nInstall: ${hint}`;
139
+ }
140
+ }
141
+ }
142
+
143
+ // Terraform errors
144
+ if (toolName === 'terraform' || e.includes('terraform')) {
145
+ if (e.includes('no such file or directory') && e.includes('.terraform')) {
146
+ return 'HINT: Run `terraform init` first — the .terraform directory is missing.';
147
+ }
148
+ if (e.includes('provider') && e.includes('required') && e.includes('terraform')) {
149
+ return 'HINT: Run `terraform init -upgrade` to download or upgrade required providers.';
150
+ }
151
+ if (e.includes('no valid credential') || e.includes('no credentials')) {
152
+ return 'HINT: AWS/cloud credentials are missing. Check `aws configure` or environment variables.';
153
+ }
154
+ if (e.includes('state lock') || e.includes('lock file')) {
155
+ return 'HINT: Terraform state is locked. If no other operation is running, use `terraform force-unlock <lock-id>`.';
156
+ }
157
+ if (e.includes('module not installed') || e.includes('module source')) {
158
+ return 'HINT: Run `terraform init` to install required modules.';
159
+ }
160
+ if (e.includes('quota') || e.includes('limit exceeded') || e.includes('vcpu')) {
161
+ return 'HINT: Cloud resource quota exceeded. Request a limit increase in the cloud console.';
162
+ }
163
+ }
164
+
165
+ // Kubernetes errors
166
+ if (toolName === 'kubectl' || toolName === 'kubectl_context') {
167
+ if (e.includes('connection refused') || e.includes('unable to connect')) {
168
+ return 'HINT: Cannot reach the Kubernetes API server. Check `kubectl config current-context` and ensure the cluster is accessible.';
169
+ }
170
+ if (e.includes('unauthorized') || e.includes('forbidden')) {
171
+ return 'HINT: Insufficient permissions. Check your kubeconfig credentials or RBAC roles.';
172
+ }
173
+ if (e.includes('not found') && e.includes('namespace')) {
174
+ return 'HINT: The namespace does not exist. Create it with `kubectl create namespace <name>` first.';
175
+ }
176
+ if (e.includes('image') && (e.includes('not found') || e.includes('pull'))) {
177
+ return 'HINT: Container image pull failed. Verify the image name, tag, and registry credentials (imagePullSecret).';
178
+ }
179
+ }
180
+
181
+ // Helm errors
182
+ if (toolName === 'helm' || toolName === 'helm_values') {
183
+ if (e.includes('chart not found') || e.includes('no such chart')) {
184
+ return 'HINT: Chart not found. Run `helm repo update` and verify the chart name.';
185
+ }
186
+ if (e.includes('release not found')) {
187
+ return 'HINT: Helm release not found. Use `helm list -A` to see all releases across namespaces.';
188
+ }
189
+ if (e.includes('unable to build kubernetes objects') || e.includes('manifest')) {
190
+ return 'HINT: Helm template rendering failed. Run `helm template <release> <chart>` to debug the manifests.';
191
+ }
192
+ }
193
+
194
+ // Cloud CLI errors
195
+ if (toolName === 'cloud_discover' || toolName === 'cloud_action') {
196
+ if (e.includes('not authorized') || e.includes('access denied') || e.includes('unauthorized')) {
197
+ return 'HINT: Cloud credentials lack required permissions. Check IAM policies/roles for the operation.';
198
+ }
199
+ if (e.includes('region') && e.includes('not found')) {
200
+ return 'HINT: Invalid region. Check `aws configure get region` or pass --region explicitly.';
201
+ }
202
+ }
203
+
204
+ // Docker errors
205
+ if (toolName === 'docker') {
206
+ if (e.includes('cannot connect to the docker daemon') || e.includes('docker daemon') || e.includes('docker.sock')) {
207
+ return 'HINT: Docker daemon is not running. Start it with `colima start` (macOS) or `sudo systemctl start docker` (Linux).';
208
+ }
209
+ if (e.includes('manifest unknown') || e.includes('manifest not found') || e.includes('not found')) {
210
+ return 'HINT: Image not found. Verify the image name and tag. Check registry credentials with `docker login`.';
211
+ }
212
+ if (e.includes('no space left on device') || e.includes('no space left')) {
213
+ return 'HINT: Docker disk space exhausted. Run `docker system prune -f` to reclaim space.';
214
+ }
215
+ if (e.includes('permission denied') && e.includes('docker')) {
216
+ return 'HINT: Docker permission denied. Add your user to the docker group: `sudo usermod -aG docker $USER`.';
217
+ }
218
+ }
219
+
220
+ // Secrets errors
221
+ if (toolName === 'secrets') {
222
+ if (e.includes('permission denied') || e.includes('403') || e.includes('accessdenied')) {
223
+ return 'HINT: Secrets access denied. Check Vault policy with `vault policy read <policy>` or IAM role permissions.';
224
+ }
225
+ if (e.includes('secret not found') || e.includes('no such secret') || e.includes('resourcenotfoundexception')) {
226
+ return 'HINT: Secret not found. Verify the secret path/name and namespace. Use `vault kv list <mount>` to browse.';
227
+ }
228
+ if (e.includes('invalid token') || e.includes('token expired')) {
229
+ return 'HINT: Vault/cloud token expired. Run `vault login` or refresh cloud credentials with `nimbus auth-refresh`.';
230
+ }
231
+ }
232
+
233
+ // CI/CD errors
234
+ if (toolName === 'cicd') {
235
+ if (e.includes('workflow not found') || e.includes('could not find workflow')) {
236
+ return 'HINT: Workflow not found. Check the workflow filename in .github/workflows/ and the branch name.';
237
+ }
238
+ if (e.includes('rate limit') || e.includes('429') || e.includes('too many requests')) {
239
+ return 'HINT: API rate limited. Wait 60 seconds and retry. Check rate limit headers for reset time.';
240
+ }
241
+ if (e.includes('unauthorized') || e.includes('401') || e.includes('bad credentials')) {
242
+ return 'HINT: CI/CD authentication failed. Check GITHUB_TOKEN, GITLAB_TOKEN, or CIRCLECI_TOKEN environment variables.';
243
+ }
244
+ }
245
+
246
+ // GitOps errors
247
+ if (toolName === 'gitops') {
248
+ if (e.includes('not found') || e.includes('not logged in') || e.includes('unauthenticated')) {
249
+ return 'HINT: ArgoCD/Flux not accessible. Check ARGOCD_SERVER and ARGOCD_TOKEN env vars, or run `argocd login`.';
250
+ }
251
+ if (e.includes('comparisonerror') || e.includes('sync error')) {
252
+ return 'HINT: GitOps sync error. Validate manifests: `kubectl apply --dry-run=client -f <manifest>` to find issues.';
253
+ }
254
+ if (e.includes('health') && e.includes('degraded')) {
255
+ return 'HINT: Application is degraded. Check pod logs with `kubectl logs -n <ns>` and events with `kubectl get events -n <ns>`.';
256
+ }
257
+ }
258
+
259
+ // Monitoring errors
260
+ if (toolName === 'monitor') {
261
+ if (e.includes('connection refused') || e.includes('could not connect')) {
262
+ return 'HINT: Cannot connect to monitoring endpoint. Check PROMETHEUS_URL, GRAFANA_URL, or cloud region configuration.';
263
+ }
264
+ if (e.includes('unauthorized') || e.includes('403')) {
265
+ return 'HINT: Monitoring authentication failed. Check DD_API_KEY, GRAFANA_TOKEN, or NEW_RELIC_API_KEY environment variables.';
266
+ }
267
+ }
268
+
269
+ // L3: Parse NIMBUS.md custom error hints section
270
+ if (nimbusInstructions) {
271
+ const hintsMatch = nimbusInstructions.match(/##\s*Custom Error Hints\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
272
+ if (hintsMatch) {
273
+ const hintsSection = hintsMatch[1];
274
+ const hintLines = hintsSection.split('\n').filter(l => l.trim().startsWith('-'));
275
+ for (const line of hintLines) {
276
+ // Format: "- pattern: hint message"
277
+ const colonIdx = line.indexOf(':');
278
+ if (colonIdx > 0) {
279
+ const pattern = line.slice(1, colonIdx).trim();
280
+ const hint = line.slice(colonIdx + 1).trim();
281
+ if (pattern && hint && errorOutput.toLowerCase().includes(pattern.toLowerCase())) {
282
+ return `HINT: ${hint}`;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ return null;
290
+ }
291
+
292
+ /** DevOps tool names that get self-diagnosis hints on unrecognized errors. */
293
+ const DEVOPS_TOOL_NAMES = new Set([
294
+ 'terraform', 'kubectl', 'kubectl_context', 'helm', 'helm_values',
295
+ 'bash', 'cloud_discover', 'drift_detect', 'deploy_preview',
296
+ 'docker', 'secrets', 'cicd', 'monitor', 'gitops', 'cloud_action',
297
+ 'logs', 'certs', 'mesh', 'cfn', 'k8s_rbac',
298
+ ]);
299
+
300
+ /**
301
+ * Format a Zod (or generic) tool-input validation error into a human-readable
302
+ * message that tells the LLM exactly which fields are wrong and how to fix them.
303
+ */
304
+ function formatToolInputError(toolName: string, err: unknown): string {
305
+ if (err && typeof err === 'object' && 'issues' in err) {
306
+ // ZodError
307
+ const issues = (err as { issues: Array<{ path: (string | number)[]; message: string }> }).issues;
308
+ const details = issues
309
+ .map(i => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
310
+ .join('\n');
311
+ return `Tool "${toolName}" received invalid input:\n${details}\n\nPlease correct the arguments and retry.`;
312
+ }
313
+ return `Tool "${toolName}" failed: ${err instanceof Error ? err.message : String(err)}`;
314
+ }
315
+
316
+ /** Determine whether a streaming error is transient and worth retrying. */
317
+ function isRetryableStreamError(err: unknown): boolean {
318
+ if (err && typeof err === 'object') {
319
+ const e = err as Record<string, unknown>;
320
+ const status =
321
+ (typeof e.status === 'number' ? e.status : undefined) ??
322
+ (typeof e.statusCode === 'number' ? e.statusCode : undefined);
323
+ if (status === 429 || (status !== undefined && status >= 500 && status < 600)) return true;
324
+ const msg = typeof e.message === 'string' ? e.message : '';
325
+ if (/rate.?limit|429|too many requests|overloaded|503/i.test(msg)) return true;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // G3: Runaway protection helpers
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /** Patterns that indicate a destructive operation in tool arguments. */
335
+ const DESTRUCTIVE_PATTERNS = /\b(apply|destroy|delete|terminate|stop|remove|drop|truncate|purge)\b/i;
336
+
337
+ /** Tool names whose destructive operations should be counted at the session level. */
338
+ const DESTRUCTIVE_TOOL_NAMES = new Set([
339
+ 'terraform', 'kubectl', 'docker', 'aws', 'gcloud', 'az', 'cloud_action', 'cfn',
340
+ ]);
341
+
342
+ /**
343
+ * Returns true if the tool call looks like a destructive infrastructure operation.
344
+ * Used to enforce the session-level destructive ops counter (G3).
345
+ */
346
+ function isDestructiveOp(toolName: string, inputStr: string): boolean {
347
+ return DESTRUCTIVE_TOOL_NAMES.has(toolName) && DESTRUCTIVE_PATTERNS.test(inputStr);
348
+ }
39
349
 
40
350
  // ---------------------------------------------------------------------------
41
351
  // Public Types
@@ -55,15 +365,47 @@ export interface AgentLoopOptions {
55
365
  /** Maximum number of LLM turns before stopping (default: 50). */
56
366
  maxTurns?: number;
57
367
 
368
+ /**
369
+ * Maximum number of tool calls allowed in a single LLM turn (G3).
370
+ * Prevents runaway tool call loops. Default: 20.
371
+ */
372
+ maxToolCallsPerTurn?: number;
373
+
374
+ /**
375
+ * Maximum number of destructive operations allowed in a single session (G3).
376
+ * Triggers a warning in the tool result when the threshold is reached. Default: 5.
377
+ */
378
+ maxDestructiveOpsPerSession?: number;
379
+
58
380
  /** Model to use (e.g. `'anthropic/claude-sonnet-4-20250514'`). */
59
381
  model?: string;
60
382
 
383
+ /**
384
+ * When true, enables automatic model routing based on task complexity (Gap 18).
385
+ * Simple queries → haiku, complex → opus, moderate → sonnet.
386
+ * Overridden if `model` is explicitly set.
387
+ */
388
+ autoRouteModel?: boolean;
389
+
61
390
  /** Current working directory. */
62
391
  cwd?: string;
63
392
 
64
393
  /** Custom NIMBUS.md content injected into the system prompt. */
65
394
  nimbusInstructions?: string;
66
395
 
396
+ /**
397
+ * Live infrastructure context (terraform workspace, kubectl context, etc.)
398
+ * discovered at startup. Injected into the system prompt (Gaps 7 & 10).
399
+ */
400
+ infraContext?: {
401
+ terraformWorkspace?: string;
402
+ kubectlContext?: string;
403
+ helmReleases?: string[];
404
+ awsAccount?: string;
405
+ awsRegion?: string;
406
+ gcpProject?: string;
407
+ };
408
+
67
409
  /** Callback for streaming text output. */
68
410
  onText?: (text: string) => void;
69
411
 
@@ -73,6 +415,12 @@ export interface AgentLoopOptions {
73
415
  /** Callback when a tool call completes. */
74
416
  onToolCallEnd?: (toolCall: ToolCallInfo, result: ToolResult) => void;
75
417
 
418
+ /**
419
+ * Callback fired for each chunk of streamed tool output (Gap 1 — live streaming).
420
+ * Called with the tool call ID and the chunk text.
421
+ */
422
+ onToolOutputChunk?: (toolId: string, chunk: string) => void;
423
+
76
424
  /**
77
425
  * Callback to check permission before tool execution.
78
426
  * If omitted, all tools are executed without prompting.
@@ -105,10 +453,63 @@ export interface AgentLoopOptions {
105
453
  * call so users can undo/redo changes. */
106
454
  snapshotManager?: SnapshotManager;
107
455
 
456
+ /** Optional hook engine for PreToolUse/PostToolUse/PermissionRequest hooks.
457
+ * When provided, matching hook scripts are executed around each tool call. */
458
+ hookEngine?: HookEngine;
459
+
108
460
  /** Callback fired after each LLM turn with accumulated usage and cost.
109
461
  * Allows the TUI to update cost/token display in real-time during
110
462
  * multi-turn agent loops, not just at the end. */
111
463
  onUsage?: (usage: AgentLoopUsage, costUSD: number) => void;
464
+
465
+ /**
466
+ * Optional callback to show a diff preview before file-mutating tools.
467
+ * If provided, the loop calls this before edit_file/multi_edit/write_file.
468
+ * Returning 'reject' skips the tool call; 'apply-all' disables further prompts.
469
+ */
470
+ requestFileDiff?: (
471
+ path: string,
472
+ toolName: string,
473
+ diff: string
474
+ ) => Promise<FileDiffDecision>;
475
+
476
+ /**
477
+ * Internal flag set by requestFileDiff 'apply-all' — skips remaining diff
478
+ * prompts for the current turn. Set externally by the TUI launcher.
479
+ */
480
+ skipRemainingDiffPrompts?: boolean;
481
+
482
+ /**
483
+ * Internal flag set by requestFileDiff 'reject-all' — auto-rejects remaining
484
+ * diff prompts for the current turn. Set externally by the TUI launcher.
485
+ */
486
+ rejectRemainingDiffPrompts?: boolean;
487
+
488
+ /**
489
+ * M1: Dry-run mode — when true, forces plan mode and prepends a hard
490
+ * constraint to the system prompt instructing the agent not to execute
491
+ * any mutating operations.
492
+ */
493
+ dryRun?: boolean;
494
+
495
+ /**
496
+ * G16: Maximum cost in USD per session. If the cumulative LLM cost exceeds
497
+ * this threshold, the loop stops and returns a budget-exceeded message.
498
+ */
499
+ costBudgetUSD?: number;
500
+
501
+ /**
502
+ * G21: Override the stream silence timeout in milliseconds.
503
+ * Defaults to config.agentTurnTimeoutSeconds * 1000, or 60_000 if not set.
504
+ */
505
+ streamSilenceTimeoutMs?: number;
506
+
507
+ /**
508
+ * GAP-20: Per-tool timeout overrides from NIMBUS.md Tool Timeouts section.
509
+ * Maps tool name to timeout in milliseconds. When set, the value is threaded
510
+ * into the tool's ToolExecuteContext so it can override the built-in default.
511
+ */
512
+ toolTimeouts?: Record<string, number>;
112
513
  }
113
514
 
114
515
  /** Information about a tool call in progress. */
@@ -121,6 +522,9 @@ export interface ToolCallInfo {
121
522
 
122
523
  /** Parsed input arguments. */
123
524
  input: unknown;
525
+
526
+ /** Unix timestamp (Date.now()) when the tool call started. */
527
+ startTime: number;
124
528
  }
125
529
 
126
530
  /**
@@ -132,6 +536,15 @@ export interface ToolCallInfo {
132
536
  */
133
537
  export type PermissionDecision = 'allow' | 'deny' | 'block';
134
538
 
539
+ /**
540
+ * Result of a per-file diff approval request.
541
+ *
542
+ * - `apply` -- apply this change.
543
+ * - `reject` -- skip this change.
544
+ * - `apply-all` -- apply this and all remaining changes without further prompts.
545
+ */
546
+ export type FileDiffDecision = 'apply' | 'reject' | 'apply-all' | 'reject-all';
547
+
135
548
  /** Aggregate token usage across all LLM turns. */
136
549
  export interface AgentLoopUsage {
137
550
  /** Total prompt (input) tokens consumed. */
@@ -169,6 +582,134 @@ export interface AgentLoopResult {
169
582
  /** Default model when none is specified. */
170
583
  const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-20250514';
171
584
 
585
+ // ---------------------------------------------------------------------------
586
+ // H5: Cost delta hint after terraform apply / helm upgrade
587
+ // ---------------------------------------------------------------------------
588
+
589
+ /**
590
+ * Extract a lightweight cost hint from tool output for display after
591
+ * infrastructure operations (terraform apply, helm install/upgrade).
592
+ */
593
+ function extractCostHintFromToolOutput(toolName: string, input: Record<string, unknown>, output: string): string | null {
594
+ // terraform apply: parse "Apply complete! Resources: N added, M changed, K destroyed."
595
+ if (toolName === 'terraform' && String(input.action) === 'apply') {
596
+ const m = output.match(/Resources:\s*(\d+) added,\s*(\d+) changed,\s*(\d+) destroyed/);
597
+ if (m) {
598
+ const added = Number(m[1]);
599
+ const changed = Number(m[2]);
600
+ const destroyed = Number(m[3]);
601
+ const parts: string[] = [];
602
+ if (added > 0) parts.push(`+${added} resources created`);
603
+ if (changed > 0) parts.push(`${changed} updated`);
604
+ if (destroyed > 0) parts.push(`${destroyed} destroyed`);
605
+ return parts.length > 0
606
+ ? `${parts.join(', ')} — run "nimbus cost" for monthly cost estimate`
607
+ : null;
608
+ }
609
+ }
610
+ // helm install/upgrade
611
+ if (toolName === 'helm' && ['install', 'upgrade'].includes(String(input.action))) {
612
+ const releaseName = String(input.releaseName ?? input.release ?? '');
613
+ if (!output.includes('Error') && !output.includes('FAILED')) {
614
+ return `Helm release "${releaseName}" deployed — run "nimbus cost" for estimated cost impact`;
615
+ }
616
+ }
617
+ return null;
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // M4: Session-scoped error tracking for NIMBUS.md persistence
622
+ // ---------------------------------------------------------------------------
623
+
624
+ const sessionErrorCounts = new Map<string, number>();
625
+
626
+ function trackAndPersistError(toolName: string, errorHint: string, cwd: string): void {
627
+ const key = `${toolName}:${errorHint.slice(0, 60)}`;
628
+ const count = (sessionErrorCounts.get(key) ?? 0) + 1;
629
+ sessionErrorCounts.set(key, count);
630
+
631
+ if (count === 3) {
632
+ try {
633
+ const { existsSync, readFileSync, writeFileSync, appendFileSync } = require('node:fs') as typeof import('node:fs');
634
+ const { join } = require('node:path') as typeof import('node:path');
635
+ const nimbusPath = join(cwd, 'NIMBUS.md');
636
+ if (!existsSync(nimbusPath)) return;
637
+ const existing = readFileSync(nimbusPath, 'utf-8');
638
+ if (existing.includes(errorHint.slice(0, 40))) return; // already recorded
639
+ const entry = `- ${toolName}: ${errorHint}\n`;
640
+ if (existing.includes('## Observed Issues')) {
641
+ writeFileSync(nimbusPath, existing.replace('## Observed Issues\n', `## Observed Issues\n${entry}`));
642
+ } else {
643
+ appendFileSync(nimbusPath, `\n## Observed Issues\n${entry}`);
644
+ }
645
+ } catch { /* non-critical */ }
646
+ }
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // M6: Destructive action guard — force confirmation before terraform destroy / kubectl delete
651
+ // ---------------------------------------------------------------------------
652
+
653
+ function isDestructiveAction(toolName: string, input: Record<string, unknown>): string | null {
654
+ const action = String(input.action ?? input.command ?? '');
655
+ if (toolName === 'terraform' && action === 'destroy') {
656
+ return 'terraform destroy will PERMANENTLY DELETE all managed infrastructure. Explicitly confirm with the user before proceeding.';
657
+ }
658
+ if (toolName === 'kubectl' && action === 'delete') {
659
+ const resource = String(input.resource ?? '');
660
+ return `kubectl delete ${resource} is IRREVERSIBLE. Explicitly confirm with the user before proceeding.`;
661
+ }
662
+ if (toolName === 'helm' && action === 'uninstall') {
663
+ return 'helm uninstall will remove the release and its resources. Explicitly confirm with the user before proceeding.';
664
+ }
665
+ return null;
666
+ }
667
+
668
+ /**
669
+ * Session-scoped terraform plan cache.
670
+ * Maps workdir → { output, timestamp } so that within one agent session,
671
+ * a plan result can be reused for the apply call without re-running tf plan.
672
+ * Cache expires after 10 minutes.
673
+ */
674
+ interface TerraformPlanCacheEntry {
675
+ output: string;
676
+ workdir: string;
677
+ timestamp: number;
678
+ }
679
+
680
+ const PLAN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
681
+ const terraformPlanCache = new Map<string, TerraformPlanCacheEntry>();
682
+
683
+ /** Store a terraform plan output for a workdir. */
684
+ function cacheTerraformPlan(workdir: string, output: string): void {
685
+ terraformPlanCache.set(workdir, { output, workdir, timestamp: Date.now() });
686
+ }
687
+
688
+ /** Retrieve a cached terraform plan for a workdir, or null if expired/missing. */
689
+ function getCachedTerraformPlan(workdir: string): string | null {
690
+ const entry = terraformPlanCache.get(workdir);
691
+ if (!entry) return null;
692
+ if (Date.now() - entry.timestamp > PLAN_CACHE_TTL_MS) {
693
+ terraformPlanCache.delete(workdir);
694
+ return null;
695
+ }
696
+ return entry.output;
697
+ }
698
+
699
+ /**
700
+ * Background interval that evicts expired terraform plan cache entries every 60s.
701
+ * `.unref()` ensures this does not prevent the process from exiting.
702
+ * Exported for test teardown.
703
+ */
704
+ export const _planCacheCleanupInterval: ReturnType<typeof setInterval> = setInterval(() => {
705
+ const now = Date.now();
706
+ for (const [key, entry] of terraformPlanCache) {
707
+ if (now - entry.timestamp > PLAN_CACHE_TTL_MS) {
708
+ terraformPlanCache.delete(key);
709
+ }
710
+ }
711
+ }, 60_000).unref();
712
+
172
713
  /** Default max output tokens per LLM call. */
173
714
  const DEFAULT_MAX_TOKENS = 8192;
174
715
 
@@ -216,6 +757,7 @@ export async function runAgentLoop(
216
757
  onText,
217
758
  onToolCallStart,
218
759
  onToolCallEnd,
760
+ onToolOutputChunk,
219
761
  checkPermission,
220
762
  signal,
221
763
  } = options;
@@ -226,11 +768,25 @@ export async function runAgentLoop(
226
768
 
227
769
  const tools = getToolsForMode(toolRegistry.getAll(), mode);
228
770
 
771
+ // H3: Auto-discover infra context if not provided and cwd is set (best-effort, cached per cwd)
772
+ let resolvedInfraContext = options.infraContext;
773
+ if (!resolvedInfraContext && cwd) {
774
+ try {
775
+ const { discoverInfraContext } = await import('../cli/init');
776
+ resolvedInfraContext = await Promise.race([
777
+ discoverInfraContext(cwd),
778
+ new Promise<undefined>(r => setTimeout(() => r(undefined), 5000)),
779
+ ]);
780
+ } catch { /* best-effort */ }
781
+ }
782
+
229
783
  const systemPrompt = buildSystemPrompt({
230
784
  mode,
231
785
  tools,
232
786
  nimbusInstructions,
233
787
  cwd,
788
+ infraContext: resolvedInfraContext,
789
+ dryRun: options.dryRun,
234
790
  });
235
791
 
236
792
  // Convert agentic ToolDefinitions to the LLM-level format expected by
@@ -241,7 +797,11 @@ export async function runAgentLoop(
241
797
  // 2. Initialize conversation state
242
798
  // -----------------------------------------------------------------------
243
799
 
244
- const messages: LLMMessage[] = [...history, { role: 'user', content: userMessage }];
800
+ // PERF-4a: Capacity-hinted pre-allocation avoids repeated V8 array reallocation
801
+ // as messages accumulate during a long conversation.
802
+ const messages: LLMMessage[] = new Array(Math.max(history.length + 1, 10));
803
+ messages.length = 0;
804
+ messages.push(...history, { role: 'user', content: userMessage });
245
805
 
246
806
  let turns = 0;
247
807
  let interrupted = false;
@@ -252,6 +812,41 @@ export async function runAgentLoop(
252
812
  };
253
813
  let totalCost = 0;
254
814
 
815
+ // G3: Session-level destructive operation counter and per-turn tool call counter
816
+ let sessionDestructiveOps = 0;
817
+ const MAX_TOOL_CALLS_PER_TURN = options.maxToolCallsPerTurn ?? 20;
818
+ const MAX_DESTRUCTIVE_OPS_PER_SESSION = options.maxDestructiveOpsPerSession ?? 5;
819
+
820
+ // M2/M5: Track tool calls that have already received a credential-error retry message
821
+ // to avoid spamming the auth-refresh hint on repeated failures.
822
+ const credentialRetried = new Set<string>();
823
+
824
+ // G8: Track which terraform workdirs have had a plan run in this session.
825
+ // Used to warn when apply is run without a prior plan.
826
+ const terraformPlannedWorkdirs = new Set<string>();
827
+
828
+ // G10: One-time kubectl RBAC pre-flight check state.
829
+ // kubectlRbacChecked: ensures we only run `kubectl auth can-i --list` once per session.
830
+ // rbacPreamble: stores the RBAC output to inject into the first kubectl tool result.
831
+ let kubectlRbacChecked = false;
832
+ let rbacPreamble = '';
833
+
834
+ // G10: Pre-import async exec utilities so they're available inside the loop.
835
+ // Using async execFile avoids blocking the Node.js event loop for kubectl/terraform calls.
836
+ const { execFile: _execFile, exec: _exec } = await import('node:child_process');
837
+ const { promisify: _promisify } = await import('node:util');
838
+ const _execFileAsync = _promisify(_execFile);
839
+ const _execAsync = _promisify(_exec);
840
+
841
+ // PERF-4a: Pre-build the system message once so it can be reused every turn
842
+ // without allocating a new object on each loop iteration.
843
+ const _systemMessageObj: LLMMessage = { role: 'system', content: systemPrompt };
844
+
845
+ // Shared mutable ref: set to true by 'apply-all' diff decision to skip further prompts
846
+ const skipRemainingDiffPrompts = { value: options.skipRemainingDiffPrompts ?? false };
847
+ // Shared mutable ref: set to true by 'reject-all' diff decision to auto-reject further prompts
848
+ const rejectRemainingDiffPrompts = { value: options.rejectRemainingDiffPrompts ?? false };
849
+
255
850
  // -----------------------------------------------------------------------
256
851
  // 3. Main agent loop
257
852
  // -----------------------------------------------------------------------
@@ -266,10 +861,30 @@ export async function runAgentLoop(
266
861
  turns++;
267
862
 
268
863
  try {
269
- // Build the completion request with tool definitions
864
+ // Gap 18: Auto-route model based on task complexity when no explicit model set
865
+ let effectiveModel = model ?? DEFAULT_MODEL;
866
+ if (!model && options.autoRouteModel) {
867
+ const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
868
+ const lastMsgText = lastUserMsg
869
+ ? typeof lastUserMsg.content === 'string'
870
+ ? lastUserMsg.content
871
+ : JSON.stringify(lastUserMsg.content)
872
+ : '';
873
+ const complexity = classifyTaskComplexity(lastMsgText);
874
+ effectiveModel = routeModel(complexity);
875
+ if (onText && turns === 1) {
876
+ onText(`\n[auto: ${effectiveModel.split('/').pop()?.replace('anthropic/', '') ?? effectiveModel}]\n`);
877
+ }
878
+ }
879
+
880
+ // Build the completion request with tool definitions.
881
+ // The systemMessageObj is pre-built before the loop (PERF-4a) — reuse it.
882
+ const allMessages: LLMMessage[] = new Array(messages.length + 1);
883
+ allMessages.length = 0;
884
+ allMessages.push(_systemMessageObj, ...messages);
270
885
  const request: ToolCompletionRequest = {
271
- messages: [{ role: 'system', content: systemPrompt }, ...messages],
272
- model: model ?? DEFAULT_MODEL,
886
+ messages: allMessages,
887
+ model: effectiveModel,
273
888
  tools: llmTools,
274
889
  maxTokens: DEFAULT_MAX_TOKENS,
275
890
  };
@@ -281,22 +896,71 @@ export async function runAgentLoop(
281
896
  let responseToolCalls: ToolCall[] | undefined;
282
897
  let responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
283
898
 
284
- for await (const chunk of router.routeStreamWithTools(request)) {
285
- if (chunk.content) {
286
- responseContent += chunk.content;
287
- if (onText) {
288
- onText(chunk.content);
899
+ // A1: Retry on transient errors (rate-limit / 5xx) with exponential backoff
900
+ const MAX_STREAM_RETRIES = 2;
901
+ let streamAttempt = 0;
902
+ while (true) {
903
+ // A2: Silence timeout — abort if no chunk arrives (G21: configurable)
904
+ const STREAM_SILENCE_MS = options.streamSilenceTimeoutMs ?? 60_000;
905
+ const silenceAbort = new AbortController();
906
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
907
+ const resetSilence = () => {
908
+ clearTimeout(silenceTimer);
909
+ silenceTimer = setTimeout(() => silenceAbort.abort('Stream timeout'), STREAM_SILENCE_MS);
910
+ };
911
+ resetSilence();
912
+
913
+ try {
914
+ // Pass silence abort signal via request cast (non-standard but supported by most providers)
915
+ const requestWithSignal = { ...request, signal: silenceAbort.signal } as typeof request;
916
+ for await (const chunk of router.routeStreamWithTools(requestWithSignal)) {
917
+ resetSilence(); // reset on every chunk
918
+ if (chunk.content) {
919
+ responseContent += chunk.content;
920
+ if (onText) {
921
+ onText(chunk.content);
922
+ }
923
+ }
924
+ if (chunk.toolCallStart && onText) {
925
+ // Show early feedback when the LLM starts composing a tool call
926
+ onText(`\n[Preparing tool: ${chunk.toolCallStart.name}...]\n`);
927
+ }
928
+ if (chunk.toolCalls) {
929
+ responseToolCalls = chunk.toolCalls;
930
+ }
931
+ if (chunk.usage) {
932
+ responseUsage = chunk.usage;
933
+ }
289
934
  }
290
- }
291
- if (chunk.toolCallStart && onText) {
292
- // Show early feedback when the LLM starts composing a tool call
293
- onText(`\n[Preparing tool: ${chunk.toolCallStart.name}...]\n`);
294
- }
295
- if (chunk.toolCalls) {
296
- responseToolCalls = chunk.toolCalls;
297
- }
298
- if (chunk.usage) {
299
- responseUsage = chunk.usage;
935
+ clearTimeout(silenceTimer);
936
+ break; // success exit retry loop
937
+ } catch (streamErr) {
938
+ clearTimeout(silenceTimer);
939
+ if (streamAttempt < MAX_STREAM_RETRIES && isRetryableStreamError(streamErr)) {
940
+ const delay = 1000 * Math.pow(2, streamAttempt);
941
+ if (onText) {
942
+ onText(`\n[Retrying after error (attempt ${streamAttempt + 1})...]\n`);
943
+ }
944
+ await new Promise(r => setTimeout(r, delay));
945
+ streamAttempt++;
946
+ // Reset partial accumulation before retry
947
+ responseContent = '';
948
+ responseToolCalls = undefined;
949
+ responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
950
+ continue;
951
+ }
952
+ // G24: Graceful network error message instead of raw Node.js error
953
+ const streamErrObj = streamErr as Error | null;
954
+ const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed|network/i.test(streamErrObj?.message ?? '');
955
+ if (isNetworkError) {
956
+ const netMsg = '\n[!!] Network unreachable — cannot reach the LLM API.\nCheck your internet connection and API key validity, then try again.\n';
957
+ if (onText) onText(netMsg);
958
+ // Re-throw a specially-marked error so the outer turn catch block can handle it
959
+ const netErr = new Error(netMsg);
960
+ (netErr as Error & { _nimbusNetworkError?: boolean })._nimbusNetworkError = true;
961
+ throw netErr;
962
+ }
963
+ throw streamErr; // non-retryable — propagate to outer catch
300
964
  }
301
965
  }
302
966
 
@@ -306,7 +970,7 @@ export async function runAgentLoop(
306
970
  totalUsage.totalTokens += responseUsage.totalTokens;
307
971
 
308
972
  // Estimate cost for this turn
309
- const resolvedModel = model ?? DEFAULT_MODEL;
973
+ const resolvedModel = effectiveModel;
310
974
  const providerName = resolvedModel.includes('/') ? resolvedModel.split('/')[0] : 'anthropic';
311
975
  const modelName = resolvedModel.includes('/')
312
976
  ? resolvedModel.split('/').slice(1).join('/')
@@ -324,6 +988,21 @@ export async function runAgentLoop(
324
988
  options.onUsage(totalUsage, totalCost);
325
989
  }
326
990
 
991
+ // M2: Emit per-turn token/cost stats as a dim system message in the TUI.
992
+ // Only emit when there was actual token usage (skip turns with 0 tokens).
993
+ if (onText && (responseUsage.promptTokens > 0 || responseUsage.completionTokens > 0)) {
994
+ const statsLine = `\n[${responseUsage.promptTokens} in / ${responseUsage.completionTokens} out — $${turnCost.costUSD.toFixed(4)}]\n`;
995
+ onText(statsLine);
996
+ }
997
+
998
+ // G16: Cost budget enforcement — stop if cumulative cost exceeds the limit
999
+ if (options.costBudgetUSD !== undefined && totalCost >= options.costBudgetUSD) {
1000
+ const budgetMsg = `\n\n[!!] Cost budget of $${options.costBudgetUSD.toFixed(2)} reached (used: $${totalCost.toFixed(3)}). Stopping to prevent overspend.\n`;
1001
+ if (onText) onText(budgetMsg);
1002
+ messages.push({ role: 'assistant', content: budgetMsg });
1003
+ break;
1004
+ }
1005
+
327
1006
  // -----------------------------------------------------------------
328
1007
  // No tool calls → the LLM is done
329
1008
  // -----------------------------------------------------------------
@@ -346,6 +1025,59 @@ export async function runAgentLoop(
346
1025
  toolCalls: responseToolCalls,
347
1026
  });
348
1027
 
1028
+ // G3: Per-turn tool call counter — reset at the start of each tool-call batch
1029
+ let turnToolCallCount = 0;
1030
+
1031
+ // H2: Parallel dispatch for read-only tools (safe to run concurrently)
1032
+ const READ_ONLY_TOOLS = new Set([
1033
+ 'read_file', 'glob', 'grep', 'cloud_discover', 'terraform_plan_analyze',
1034
+ 'kubectl_context', 'helm_values', 'cost_estimate', 'drift_detect',
1035
+ ]);
1036
+ const canRunInParallel = (tc: ToolCall): boolean => READ_ONLY_TOOLS.has(tc.function.name);
1037
+ const allReadOnly = responseToolCalls.every(canRunInParallel);
1038
+
1039
+ if (allReadOnly && responseToolCalls.length > 1) {
1040
+ // All tools are read-only — dispatch in parallel
1041
+ const parallelChunkCallback = onToolOutputChunk
1042
+ ? (id: string) => (chunk: string) => onToolOutputChunk(id, chunk)
1043
+ : undefined;
1044
+
1045
+ const parallelResults = await Promise.allSettled(
1046
+ responseToolCalls.map(tc =>
1047
+ executeToolCall(
1048
+ tc,
1049
+ toolRegistry,
1050
+ onToolCallStart,
1051
+ onToolCallEnd,
1052
+ checkPermission,
1053
+ options.lspManager,
1054
+ options.snapshotManager,
1055
+ options.sessionId,
1056
+ signal,
1057
+ options.hookEngine,
1058
+ mode,
1059
+ options.requestFileDiff,
1060
+ skipRemainingDiffPrompts,
1061
+ rejectRemainingDiffPrompts,
1062
+ parallelChunkCallback ? parallelChunkCallback(tc.id) : undefined,
1063
+ options.toolTimeouts,
1064
+ options.infraContext
1065
+ )
1066
+ )
1067
+ );
1068
+
1069
+ for (let pi = 0; pi < responseToolCalls.length; pi++) {
1070
+ const tc = responseToolCalls[pi];
1071
+ const pResult = parallelResults[pi];
1072
+ const pContent = pResult.status === 'fulfilled'
1073
+ ? (pResult.value.isError ? `Error: ${pResult.value.error}` : pResult.value.output)
1074
+ : `Error: ${pResult.reason}`;
1075
+ messages.push({ role: 'tool', toolCallId: tc.id, name: tc.function.name, content: pContent });
1076
+ }
1077
+ // Skip sequential processing — jump directly to next LLM turn
1078
+ continue;
1079
+ }
1080
+
349
1081
  // Process tool calls sequentially (order may matter for side effects)
350
1082
  for (const toolCall of responseToolCalls) {
351
1083
  // Check for cancellation between tool calls
@@ -354,6 +1086,59 @@ export async function runAgentLoop(
354
1086
  break;
355
1087
  }
356
1088
 
1089
+ // G3: Enforce per-turn tool call limit to prevent runaway loops
1090
+ turnToolCallCount++;
1091
+ if (turnToolCallCount > MAX_TOOL_CALLS_PER_TURN) {
1092
+ messages.push({
1093
+ role: 'tool',
1094
+ toolCallId: toolCall.id,
1095
+ name: toolCall.function.name,
1096
+ content: `[Tool limit reached: ${MAX_TOOL_CALLS_PER_TURN} tool calls in this turn. Summarizing progress and stopping to avoid runaway execution.]`,
1097
+ });
1098
+ break;
1099
+ }
1100
+
1101
+ // G3: Count destructive operations at the session level
1102
+ if (isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
1103
+ sessionDestructiveOps++;
1104
+ }
1105
+
1106
+ // G10: One-time kubectl RBAC pre-flight check — runs before the first kubectl call
1107
+ // in this session. Stores the RBAC permissions summary in rbacPreamble so it can
1108
+ // be injected into the first kubectl tool result (keeps conversation structure valid).
1109
+ // Uses async execFile to avoid blocking the Node.js event loop (up to 5s call).
1110
+ if (!kubectlRbacChecked && toolCall.function.name === 'kubectl') {
1111
+ kubectlRbacChecked = true;
1112
+ try {
1113
+ const { stdout: rbacOut } = await _execFileAsync('kubectl', ['auth', 'can-i', '--list'], {
1114
+ encoding: 'utf-8', timeout: 5000,
1115
+ });
1116
+ const truncated = rbacOut.length > 1500
1117
+ ? `${rbacOut.slice(0, 1500)}\n...[truncated]`
1118
+ : rbacOut;
1119
+ rbacPreamble = `[kubectl RBAC context: permissions available in current context]\n${truncated}\n\n`;
1120
+ } catch { /* non-critical — RBAC check failure does not block kubectl */ }
1121
+ }
1122
+
1123
+ // M6: Destructive action guard — inject warning into LLM context before executing
1124
+ try {
1125
+ const m6Input = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1126
+ const destructiveWarning = isDestructiveAction(toolCall.function.name, m6Input);
1127
+ if (destructiveWarning) {
1128
+ messages.push({
1129
+ role: 'tool',
1130
+ toolCallId: toolCall.id + '-guard',
1131
+ name: toolCall.function.name,
1132
+ content: `[SAFETY] ${destructiveWarning}`,
1133
+ });
1134
+ }
1135
+ } catch { /* ignore parse errors */ }
1136
+
1137
+ // Build chunk callback that forwards tool output to the TUI in real-time
1138
+ const chunkCallback = onToolOutputChunk
1139
+ ? (chunk: string) => onToolOutputChunk(toolCall.id, chunk)
1140
+ : undefined;
1141
+
357
1142
  const result = await executeToolCall(
358
1143
  toolCall,
359
1144
  toolRegistry,
@@ -363,17 +1148,286 @@ export async function runAgentLoop(
363
1148
  options.lspManager,
364
1149
  options.snapshotManager,
365
1150
  options.sessionId,
366
- signal
1151
+ signal,
1152
+ options.hookEngine,
1153
+ mode,
1154
+ options.requestFileDiff,
1155
+ skipRemainingDiffPrompts,
1156
+ rejectRemainingDiffPrompts,
1157
+ chunkCallback,
1158
+ options.toolTimeouts,
1159
+ options.infraContext
367
1160
  );
368
1161
 
369
1162
  // Append each tool result as a separate message so the LLM can
370
1163
  // match it to the corresponding tool_use block by toolCallId.
371
1164
  let toolContent = result.isError ? `Error: ${result.error}` : result.output;
372
1165
 
1166
+ // G10: Inject RBAC context preamble into the first kubectl result
1167
+ if (rbacPreamble && toolCall.function.name === 'kubectl') {
1168
+ toolContent = rbacPreamble + toolContent;
1169
+ rbacPreamble = ''; // consume once — only injected into the first kubectl result
1170
+ }
1171
+
1172
+ // Inject DevOps error classification hints to guide self-correction
1173
+ if (result.isError && result.error) {
1174
+ const hint = classifyDevOpsError(toolCall.function.name, result.error, options.nimbusInstructions);
1175
+ if (hint) {
1176
+ toolContent += `\n\n${hint}`;
1177
+ // C4: Also show hint in TUI error output (not just LLM context)
1178
+ result.output += `\n\n${hint}`;
1179
+
1180
+ // M2/M5: Auto-retry signal on credential expiry errors
1181
+ // If the classified hint indicates a credential/auth problem, append
1182
+ // a structured prompt so the agent knows to run auth-refresh, and
1183
+ // set provider-specific env hints for the auth-refresh command.
1184
+ const isCredentialError =
1185
+ hint.toLowerCase().includes('credential') ||
1186
+ hint.toLowerCase().includes('expired') ||
1187
+ hint.toLowerCase().includes('auth') ||
1188
+ hint.toLowerCase().includes('login required');
1189
+
1190
+ if (isCredentialError && !credentialRetried.has(toolCall.id ?? toolCall.function.name)) {
1191
+ credentialRetried.add(toolCall.id ?? toolCall.function.name);
1192
+
1193
+ // M5: Set provider-specific refresh hint env vars so auth-refresh
1194
+ // can surface targeted guidance when invoked by the user.
1195
+ const errorLower = (result.error ?? '').toLowerCase();
1196
+ if (errorLower.includes('aws')) {
1197
+ process.env.NIMBUS_AWS_REFRESH_HINT = '1';
1198
+ }
1199
+ if (errorLower.includes('gcp') || errorLower.includes('google')) {
1200
+ process.env.NIMBUS_GCP_REFRESH_HINT = '1';
1201
+ }
1202
+ if (errorLower.includes('azure')) {
1203
+ process.env.NIMBUS_AZURE_REFRESH_HINT = '1';
1204
+ }
1205
+
1206
+ const refreshMsg = [
1207
+ '[!!] Credential expired. Run: nimbus auth-refresh',
1208
+ '[Nimbus] Credential error detected on tool: ' + toolCall.function.name,
1209
+ 'Run "nimbus auth-refresh" to refresh cloud credentials, then retry.',
1210
+ ].join('\n');
1211
+ toolContent += '\n\n' + refreshMsg;
1212
+ result.output += '\n\n' + refreshMsg;
1213
+ }
1214
+ } else if (DEVOPS_TOOL_NAMES.has(toolCall.function.name)) {
1215
+ // Unknown DevOps error — provide structured self-diagnosis steps
1216
+ toolContent += [
1217
+ '\n\n--- Self-Diagnosis Steps ---',
1218
+ '1. Check tool is installed: `which terraform` / `kubectl version` / `helm version`',
1219
+ '2. Check credentials: `aws sts get-caller-identity` / `gcloud auth list` / `az account show`',
1220
+ '3. Check network connectivity to the cluster/cloud provider',
1221
+ '4. Retry with verbose flag if available (e.g., TF_LOG=DEBUG, kubectl --v=6)',
1222
+ '5. If the error persists, report the exact error message and the command that caused it.',
1223
+ ].join('\n');
1224
+ }
1225
+ // M4: Track recurring errors and persist to NIMBUS.md after 3 occurrences
1226
+ const m4Hint = classifyDevOpsError(toolCall.function.name, result.error ?? '', options.nimbusInstructions);
1227
+ if (m4Hint) {
1228
+ trackAndPersistError(toolCall.function.name, m4Hint, options.cwd ?? process.cwd());
1229
+ }
1230
+ }
1231
+
1232
+ // H5: Inject cost delta hint after successful infra operations
1233
+ if (!result.isError) {
1234
+ try {
1235
+ const h5Input = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1236
+ const costHint = extractCostHintFromToolOutput(toolCall.function.name, h5Input, result.output);
1237
+ if (costHint) {
1238
+ onText?.(`\n[cost] ${costHint}\n`);
1239
+ }
1240
+ } catch { /* ignore parse errors */ }
1241
+ }
1242
+
1243
+ // L6: Auto-generate runbook after terraform apply success
1244
+ if (!result.isError && toolCall.function.name === 'terraform') {
1245
+ try {
1246
+ const l6Input = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1247
+ if (String(l6Input.action) === 'apply') {
1248
+ const l6Match = result.output.match(/Resources:\s*(\d+) added/);
1249
+ if (l6Match && parseInt(l6Match[1] ?? '0', 10) > 0) {
1250
+ const { join: _l6Join } = require('node:path') as typeof import('node:path');
1251
+ const { homedir: _l6Homedir } = require('node:os') as typeof import('node:os');
1252
+ const { mkdirSync: _l6MkdirSync, writeFileSync: _l6WriteFileSync } = require('node:fs') as typeof import('node:fs');
1253
+ const runbookDir = _l6Join(_l6Homedir(), '.nimbus', 'runbooks');
1254
+ _l6MkdirSync(runbookDir, { recursive: true });
1255
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1256
+ const runbookPath = _l6Join(runbookDir, `terraform-apply-${ts}.md`);
1257
+ const runbookContent = [
1258
+ '# Terraform Apply Runbook',
1259
+ '',
1260
+ `Date: ${new Date().toLocaleString()}`,
1261
+ '',
1262
+ 'Apply output:',
1263
+ '```',
1264
+ result.output.slice(0, 2000),
1265
+ '```',
1266
+ '',
1267
+ '## Rollback',
1268
+ '',
1269
+ 'To rollback, run `terraform destroy` or restore from a previous state.',
1270
+ ].join('\n');
1271
+ _l6WriteFileSync(runbookPath, runbookContent, 'utf-8');
1272
+ options.onText?.(`\n[runbook] Saved to ${runbookPath}\n`);
1273
+ }
1274
+ }
1275
+ } catch { /* non-critical */ }
1276
+ }
1277
+
1278
+ // GAP-25: Structured audit trail for destructive operations
1279
+ if (!result.isError && isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
1280
+ try {
1281
+ const { appendFileSync, mkdirSync } = await import('node:fs');
1282
+ const { homedir } = await import('node:os');
1283
+ const { join } = await import('node:path');
1284
+ const auditDir = join(homedir(), '.nimbus');
1285
+ mkdirSync(auditDir, { recursive: true });
1286
+ const event = JSON.stringify({
1287
+ type: 'infra-change',
1288
+ tool: toolCall.function.name,
1289
+ action: (JSON.parse(toolCall.function.arguments) as Record<string, unknown>).action,
1290
+ sessionId: options.sessionId ?? 'unknown',
1291
+ cwd: options.cwd ?? process.cwd(),
1292
+ timestamp: new Date().toISOString(),
1293
+ });
1294
+ appendFileSync(join(auditDir, 'audit.jsonl'), event + '\n', 'utf-8');
1295
+ } catch { /* audit logging is non-critical */ }
1296
+ }
1297
+
1298
+ // G3: Append a warning when session-level destructive op threshold is reached
1299
+ if (sessionDestructiveOps >= MAX_DESTRUCTIVE_OPS_PER_SESSION) {
1300
+ toolContent += `\n\n[Warning: ${sessionDestructiveOps} destructive operations executed in this session. Review changes carefully.]`;
1301
+ }
1302
+
1303
+ // Cache terraform plan output so a subsequent apply can reference it.
1304
+ // Also track planned workdirs (G8) and warn on unplanned applies.
1305
+ if (toolCall.function.name === 'terraform' && !result.isError) {
1306
+ try {
1307
+ const tfArgs = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1308
+ if (tfArgs.action === 'plan' && tfArgs.workdir) {
1309
+ cacheTerraformPlan(String(tfArgs.workdir), result.output);
1310
+ // G8: Track that a plan was run for this workdir in this session
1311
+ terraformPlannedWorkdirs.add(String(tfArgs.workdir));
1312
+ }
1313
+ // G8: Warn if apply ran without a prior plan in this session
1314
+ if (tfArgs.action === 'apply' && tfArgs.workdir && !terraformPlannedWorkdirs.has(String(tfArgs.workdir))) {
1315
+ 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}`;
1316
+ }
1317
+ // Inject cached plan into apply context for the LLM
1318
+ if (tfArgs.action === 'apply' && tfArgs.workdir) {
1319
+ const cached = getCachedTerraformPlan(String(tfArgs.workdir));
1320
+ if (cached) {
1321
+ toolContent = `[Apply succeeded. This was the plan that was applied:]\n${cached.slice(0, 3000)}\n\n[Apply output:]\n${toolContent}`;
1322
+ }
1323
+ }
1324
+ } catch { /* ignore parse errors */ }
1325
+ }
1326
+
1327
+ // GAP-11: trigger FileDiff UI after terraform plan shows resource changes
1328
+ if (toolCall.function.name === 'terraform' && !result.isError && options.requestFileDiff) {
1329
+ try {
1330
+ const tfArgs11 = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1331
+ if (tfArgs11.action === 'plan') {
1332
+ const { parseTerraformPlanOutput, buildFileDiffBatchFromPlan } = await import('./deploy-preview');
1333
+ const changes = parseTerraformPlanOutput(toolContent);
1334
+ if (changes.length > 0) {
1335
+ const batchFiles = buildFileDiffBatchFromPlan({ changes } as import('./deploy-preview').DeployPreview);
1336
+ for (const file of batchFiles) {
1337
+ const decision = await options.requestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
1338
+ if (decision === 'reject-all') break;
1339
+ }
1340
+ }
1341
+ }
1342
+ } catch { /* non-critical — FileDiff UI not always available */ }
1343
+ }
1344
+
1345
+ // GAP-18: auto-validate terraform files after write/edit tool calls
1346
+ if (['write_file', 'edit_file', 'multi_edit'].includes(toolCall.function.name) && !result.isError) {
1347
+ const gap18Input = JSON.parse(toolCall.function.arguments) as { path?: string; file_path?: string };
1348
+ const gap18FilePath = gap18Input.path ?? gap18Input.file_path ?? '';
1349
+ if (gap18FilePath.endsWith('.tf')) {
1350
+ try {
1351
+ // Use async exec to avoid blocking the event loop (up to 10s for terraform validate)
1352
+ const { stdout: validateOut } = await _execAsync('terraform validate -json 2>/dev/null', {
1353
+ cwd: options.cwd ?? process.cwd(),
1354
+ encoding: 'utf-8',
1355
+ timeout: 10_000,
1356
+ });
1357
+ const parsed = JSON.parse(validateOut) as { valid: boolean; diagnostics?: Array<{ severity: string; summary: string; detail: string }> };
1358
+ if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
1359
+ const errors = parsed.diagnostics
1360
+ .filter(d => d.severity === 'error')
1361
+ .map(d => ` ${d.summary}: ${d.detail}`)
1362
+ .join('\n');
1363
+ toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
1364
+ }
1365
+ } catch { /* terraform not available or not in tf project — ignore */ }
1366
+ }
1367
+ }
1368
+
373
1369
  // Truncate excessively large tool outputs to prevent context overflow
374
1370
  if (toolContent.length > MAX_TOOL_OUTPUT_CHARS) {
375
- const truncatedLength = toolContent.length;
376
- toolContent = `${toolContent.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n\n... [Output truncated: ${truncatedLength.toLocaleString()} chars total, showing first ${MAX_TOOL_OUTPUT_CHARS.toLocaleString()}]`;
1371
+ let head: string;
1372
+ let tail: string;
1373
+ let omitted: number;
1374
+ const lines = toolContent.split('\n');
1375
+
1376
+ // C3: Smart truncation for terraform plan — preserve all diff lines
1377
+ const isTerraformPlan = toolCall.function.name === 'terraform' && (() => {
1378
+ try {
1379
+ const tfArgs = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
1380
+ return tfArgs.action === 'plan';
1381
+ } catch { return false; }
1382
+ })();
1383
+
1384
+ if (isTerraformPlan) {
1385
+ // Keep all diff lines (create/update/destroy/replace) and the plan summary
1386
+ const diffLines: string[] = [];
1387
+ const contextLines: string[] = [];
1388
+ for (const line of lines) {
1389
+ const trimmed = line.trimStart();
1390
+ const isDiffLine = trimmed.startsWith('+') || trimmed.startsWith('-') ||
1391
+ trimmed.startsWith('~') || trimmed.startsWith('!') ||
1392
+ line.includes('will be created') || line.includes('will be destroyed') ||
1393
+ line.includes('will be updated') || line.includes('will be replaced') ||
1394
+ line.includes('Plan:') || line.includes('No changes') ||
1395
+ line.includes('Error:') || line.includes('Warning:');
1396
+ if (isDiffLine) {
1397
+ diffLines.push(line);
1398
+ } else {
1399
+ contextLines.push(line);
1400
+ }
1401
+ }
1402
+ // Allow up to 500 diff lines + first 50 context lines
1403
+ const keptDiff = diffLines.slice(0, 500);
1404
+ const keptCtx = contextLines.slice(0, 50);
1405
+ omitted = Math.max(0, lines.length - keptDiff.length - keptCtx.length);
1406
+ head = [...keptCtx, ...keptDiff].join('\n');
1407
+ tail = '';
1408
+ } else {
1409
+ const headLines = 100, tailLines = 20;
1410
+ head = lines.slice(0, headLines).join('\n');
1411
+ tail = lines.slice(-tailLines).join('\n');
1412
+ omitted = Math.max(0, lines.length - headLines - tailLines);
1413
+ }
1414
+
1415
+ // Save full output to disk for reference
1416
+ try {
1417
+ const { mkdirSync: _mkdirSync, writeFileSync: _writeFileSync } = await import('node:fs');
1418
+ const { homedir: _homedir } = await import('node:os');
1419
+ const outDir = join(_homedir(), '.nimbus', 'tool-outputs');
1420
+ _mkdirSync(outDir, { recursive: true });
1421
+ const outFile = join(outDir, `${Date.now()}-${toolCall.function.name}.log`);
1422
+ _writeFileSync(outFile, toolContent, 'utf-8');
1423
+ toolContent = omitted > 0
1424
+ ? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — full output saved to ' + outFile + '] ...\n\n' + tail : '\n\n... [full output saved to ' + outFile + ']'}`
1425
+ : `${head}${tail ? '\n\n' + tail : ''}`;
1426
+ } catch {
1427
+ toolContent = omitted > 0
1428
+ ? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — output too large for context] ...\n\n' + tail : '\n\n... [' + omitted + ' lines omitted]'}`
1429
+ : `${head}${tail ? '\n\n' + tail : ''}`;
1430
+ }
377
1431
  }
378
1432
 
379
1433
  messages.push({
@@ -403,10 +1457,15 @@ export async function runAgentLoop(
403
1457
  );
404
1458
  if (options.contextManager.shouldCompact(systemPrompt, messages, toolTokens)) {
405
1459
  try {
406
- const compactResult = await runCompaction(messages, options.contextManager, { router });
1460
+ const compactResult = await runCompaction(messages, options.contextManager, {
1461
+ router,
1462
+ ...(options.infraContext ? { infraContext: options.infraContext } : {}),
1463
+ });
407
1464
  // Replace messages with the compacted version
408
1465
  messages.length = 0;
409
1466
  messages.push(...compactResult.messages);
1467
+ // Clear the token cache after compaction — old message entries are no longer valid
1468
+ options.contextManager.clearTokenCache();
410
1469
  if (options.onCompact) {
411
1470
  options.onCompact(compactResult.result);
412
1471
  }
@@ -425,12 +1484,14 @@ export async function runAgentLoop(
425
1484
  } catch (error: unknown) {
426
1485
  // LLM API error — report to the caller and break
427
1486
  const msg = error instanceof Error ? error.message : String(error);
428
- if (onText) {
1487
+ // G24: Network errors already printed via onText above — skip duplicate output
1488
+ const isNetworkErr = (error instanceof Error) && (error as Error & { _nimbusNetworkError?: boolean })._nimbusNetworkError;
1489
+ if (!isNetworkErr && onText) {
429
1490
  onText(`\n[Error: ${msg}]\n`);
430
1491
  }
431
1492
  messages.push({
432
1493
  role: 'assistant',
433
- content: `I encountered an error: ${msg}`,
1494
+ content: isNetworkErr ? msg : `I encountered an error: ${msg}`,
434
1495
  });
435
1496
  break;
436
1497
  }
@@ -446,6 +1507,33 @@ export async function runAgentLoop(
446
1507
  }
447
1508
  }
448
1509
 
1510
+ // GAP-19: Session summary after multi-step deploy
1511
+ if (options.mode === 'deploy' && options.onText) {
1512
+ // Collect tool calls from messages
1513
+ const allToolCalls: Array<{ name: string; input: Record<string, unknown> }> = [];
1514
+ for (const msg of messages) {
1515
+ if (msg.role === 'assistant' && Array.isArray((msg as {toolCalls?: unknown[]}).toolCalls)) {
1516
+ for (const tc of (msg as {toolCalls: Array<{function: {name: string; arguments: string}}>}).toolCalls) {
1517
+ try {
1518
+ allToolCalls.push({ name: tc.function.name, input: JSON.parse(tc.function.arguments) as Record<string, unknown> });
1519
+ } catch { /* ignore */ }
1520
+ }
1521
+ }
1522
+ }
1523
+ if (allToolCalls.length > 3) {
1524
+ const terraform = allToolCalls.filter(c => c.name === 'terraform');
1525
+ const kubectl = allToolCalls.filter(c => c.name === 'kubectl');
1526
+ const helm = allToolCalls.filter(c => c.name === 'helm');
1527
+ const summaryLines: string[] = ['---', '**Session Summary**'];
1528
+ if (terraform.length) summaryLines.push(`• Terraform: ${terraform.map(c => String(c.input.action ?? '')).join(', ')}`);
1529
+ if (kubectl.length) summaryLines.push(`• Kubectl: ${kubectl.map(c => String(c.input.action ?? '')).join(', ')}`);
1530
+ if (helm.length) summaryLines.push(`• Helm: ${helm.map(c => String(c.input.action ?? '')).join(', ')}`);
1531
+ if (summaryLines.length > 2) {
1532
+ options.onText('\n\n' + summaryLines.join('\n'));
1533
+ }
1534
+ }
1535
+ }
1536
+
449
1537
  return {
450
1538
  messages,
451
1539
  turns,
@@ -462,6 +1550,79 @@ export async function runAgentLoop(
462
1550
  /** Tools that modify files and should trigger LSP diagnostics. */
463
1551
  const FILE_EDITING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
464
1552
 
1553
+ /** Tools that mutate files and may require a pre-approval diff. */
1554
+ const FILE_MUTATING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
1555
+
1556
+ /**
1557
+ * Generate a simple unified diff between two strings.
1558
+ * Suitable for display; uses a greedy line-by-line approach.
1559
+ */
1560
+ function generateUnifiedDiff(filename: string, before: string, after: string): string {
1561
+ const beforeLines = before.split('\n');
1562
+ const afterLines = after.split('\n');
1563
+ const lines: string[] = [`--- a/${filename}`, `+++ b/${filename}`];
1564
+ let i = 0;
1565
+ let j = 0;
1566
+ while (i < beforeLines.length || j < afterLines.length) {
1567
+ if (beforeLines[i] === afterLines[j]) {
1568
+ i++;
1569
+ j++;
1570
+ continue;
1571
+ }
1572
+ const hunkBefore: string[] = [];
1573
+ const hunkAfter: string[] = [];
1574
+ const start = i;
1575
+ while (i < beforeLines.length && beforeLines[i] !== afterLines[j]) {
1576
+ hunkBefore.push(beforeLines[i++]);
1577
+ }
1578
+ while (
1579
+ j < afterLines.length &&
1580
+ (i >= beforeLines.length || beforeLines[i] !== afterLines[j])
1581
+ ) {
1582
+ hunkAfter.push(afterLines[j++]);
1583
+ }
1584
+ lines.push(
1585
+ `@@ -${start + 1},${hunkBefore.length} +${start + 1},${hunkAfter.length} @@`
1586
+ );
1587
+ hunkBefore.forEach(l => lines.push(`-${l}`));
1588
+ hunkAfter.forEach(l => lines.push(`+${l}`));
1589
+ }
1590
+ return lines.join('\n');
1591
+ }
1592
+
1593
+ /**
1594
+ * Compute a proposed diff for a file-mutating tool call without writing to disk.
1595
+ * Returns the unified diff string, or null if it cannot be computed.
1596
+ */
1597
+ async function computeProposedDiff(
1598
+ toolName: string,
1599
+ args: Record<string, unknown>
1600
+ ): Promise<string | null> {
1601
+ try {
1602
+ const { readFile } = await import('node:fs/promises');
1603
+ const path = args.path as string;
1604
+ if (!path) return null;
1605
+ const currentContent = await readFile(path, 'utf-8').catch(() => '');
1606
+ let proposed = currentContent;
1607
+ if (toolName === 'edit_file') {
1608
+ proposed = currentContent.replace(args.old_string as string, args.new_string as string);
1609
+ } else if (toolName === 'multi_edit') {
1610
+ const edits = args.edits as Array<{ old_string: string; new_string: string }>;
1611
+ if (Array.isArray(edits)) {
1612
+ for (const e of edits) {
1613
+ proposed = proposed.replace(e.old_string, e.new_string);
1614
+ }
1615
+ }
1616
+ } else if (toolName === 'write_file') {
1617
+ proposed = args.content as string;
1618
+ }
1619
+ if (proposed === currentContent) return null; // no change
1620
+ return generateUnifiedDiff(path, currentContent, proposed);
1621
+ } catch {
1622
+ return null;
1623
+ }
1624
+ }
1625
+
465
1626
  /**
466
1627
  * Extract the file path from a tool call's parsed arguments.
467
1628
  *
@@ -507,7 +1668,15 @@ async function executeToolCall(
507
1668
  lspManager?: LSPManager,
508
1669
  snapshotManager?: SnapshotManager,
509
1670
  sessionId?: string,
510
- signal?: AbortSignal
1671
+ signal?: AbortSignal,
1672
+ hookEngine?: HookEngine,
1673
+ mode?: AgentMode,
1674
+ requestFileDiff?: (path: string, toolName: string, diff: string) => Promise<FileDiffDecision>,
1675
+ skipRemainingDiffPrompts?: { value: boolean },
1676
+ rejectRemainingDiffPrompts?: { value: boolean },
1677
+ onChunk?: (chunk: string) => void,
1678
+ toolTimeouts?: Record<string, number>,
1679
+ infraContext?: import('../sessions/manager').SessionInfraContext
511
1680
  ): Promise<ToolResult> {
512
1681
  const toolName = toolCall.function.name;
513
1682
 
@@ -518,7 +1687,7 @@ async function executeToolCall(
518
1687
  } catch {
519
1688
  const result: ToolResult = {
520
1689
  output: '',
521
- error: `Failed to parse tool arguments as JSON for '${toolName}': ${toolCall.function.arguments}`,
1690
+ error: `Tool '${toolName}' received malformed JSON arguments please retry the tool call with valid JSON. Received: ${toolCall.function.arguments.slice(0, 200)}`,
522
1691
  isError: true,
523
1692
  };
524
1693
  return result;
@@ -528,6 +1697,7 @@ async function executeToolCall(
528
1697
  id: toolCall.id,
529
1698
  name: toolName,
530
1699
  input: parsedArgs,
1700
+ startTime: Date.now(),
531
1701
  };
532
1702
 
533
1703
  // Look up the tool definition
@@ -549,6 +1719,31 @@ async function executeToolCall(
549
1719
  onStart(callInfo);
550
1720
  }
551
1721
 
1722
+ // Build shared hook context for PreToolUse and PostToolUse
1723
+ const hookContext: HookContext = {
1724
+ tool: toolName,
1725
+ input: parsedArgs && typeof parsedArgs === 'object' ? (parsedArgs as Record<string, unknown>) : {},
1726
+ sessionId: sessionId ?? 'default',
1727
+ agent: mode ?? 'build',
1728
+ timestamp: new Date().toISOString(),
1729
+ };
1730
+
1731
+ // PreToolUse hooks — may block the tool call
1732
+ if (hookEngine) {
1733
+ const preResult = await runPreToolHooks(hookEngine, hookContext);
1734
+ if (!preResult.allowed) {
1735
+ const result: ToolResult = {
1736
+ output: '',
1737
+ error: `Tool '${toolName}' blocked by hook: ${preResult.message ?? 'no reason given'}`,
1738
+ isError: true,
1739
+ };
1740
+ if (onEnd) {
1741
+ onEnd(callInfo, result);
1742
+ }
1743
+ return result;
1744
+ }
1745
+ }
1746
+
552
1747
  // Permission check
553
1748
  if (checkPermission) {
554
1749
  const decision = await checkPermission(tool, parsedArgs);
@@ -568,6 +1763,55 @@ async function executeToolCall(
568
1763
  }
569
1764
  }
570
1765
 
1766
+ // B1: Pre-approval diff — show proposed change before writing files
1767
+ if (
1768
+ FILE_MUTATING_TOOLS.has(toolName) &&
1769
+ requestFileDiff &&
1770
+ !(skipRemainingDiffPrompts?.value)
1771
+ ) {
1772
+ // Auto-reject if 'reject-all' was previously chosen
1773
+ if (rejectRemainingDiffPrompts?.value) {
1774
+ const rejResult: ToolResult = {
1775
+ output: 'User rejected this change (reject-all).',
1776
+ error: undefined,
1777
+ isError: false,
1778
+ };
1779
+ if (onEnd) onEnd(callInfo, rejResult);
1780
+ return rejResult;
1781
+ }
1782
+
1783
+ const diff = await computeProposedDiff(toolName, parsedArgs as Record<string, unknown>);
1784
+ if (diff) {
1785
+ const targetPath =
1786
+ (parsedArgs as Record<string, unknown>).path as string | undefined ?? '(file)';
1787
+ const decision = await requestFileDiff(targetPath, toolName, diff);
1788
+ if (decision === 'reject') {
1789
+ const rejResult: ToolResult = {
1790
+ output: 'User rejected this change.',
1791
+ error: undefined,
1792
+ isError: false,
1793
+ };
1794
+ if (onEnd) onEnd(callInfo, rejResult);
1795
+ return rejResult;
1796
+ }
1797
+ if (decision === 'reject-all') {
1798
+ if (rejectRemainingDiffPrompts) {
1799
+ rejectRemainingDiffPrompts.value = true;
1800
+ }
1801
+ const rejResult: ToolResult = {
1802
+ output: 'User rejected this change (reject-all).',
1803
+ error: undefined,
1804
+ isError: false,
1805
+ };
1806
+ if (onEnd) onEnd(callInfo, rejResult);
1807
+ return rejResult;
1808
+ }
1809
+ if (decision === 'apply-all' && skipRemainingDiffPrompts) {
1810
+ skipRemainingDiffPrompts.value = true;
1811
+ }
1812
+ }
1813
+ }
1814
+
571
1815
  // Capture snapshot before file-modifying tools for undo/redo support
572
1816
  if (
573
1817
  snapshotManager &&
@@ -595,12 +1839,33 @@ async function executeToolCall(
595
1839
  (validatedInput as Record<string, unknown>)._signal = signal;
596
1840
  }
597
1841
 
598
- result = await tool.execute(validatedInput);
1842
+ // GAP-20: Build tool execute context, including per-tool timeout from toolTimeouts map
1843
+ // C2: Also pass infraContext from session so tools can use it as fallback
1844
+ const toolCtx: ToolExecuteContext | undefined = onChunk || toolTimeouts?.[toolName] || infraContext
1845
+ ? {
1846
+ ...(onChunk ? { onProgress: onChunk } : {}),
1847
+ ...(toolTimeouts?.[toolName] !== undefined ? { timeout: toolTimeouts[toolName] } : {}),
1848
+ ...(infraContext ? { infraContext } : {}),
1849
+ }
1850
+ : undefined;
1851
+ // C2: Write infra checkpoint before mutating terraform/helm operations
1852
+ if (toolName === 'terraform' || toolName === 'helm') {
1853
+ const _cpArgs = parsedArgs && typeof parsedArgs === 'object'
1854
+ ? (parsedArgs as Record<string, unknown>)
1855
+ : {};
1856
+ const _cpAction = String(_cpArgs.action ?? '');
1857
+ const _cpNeedCheckpoint =
1858
+ (toolName === 'terraform' && _cpAction === 'apply') ||
1859
+ (toolName === 'helm' && ['install', 'upgrade', 'rollback'].includes(_cpAction));
1860
+ if (_cpNeedCheckpoint) {
1861
+ writeInfraCheckpoint(toolName, _cpAction, _cpArgs);
1862
+ }
1863
+ }
1864
+ result = await tool.execute(validatedInput, toolCtx);
599
1865
  } catch (error: unknown) {
600
- const msg = error instanceof Error ? error.message : String(error);
601
1866
  result = {
602
1867
  output: '',
603
- error: `Tool execution failed: ${msg}`,
1868
+ error: formatToolInputError(toolName, error),
604
1869
  isError: true,
605
1870
  };
606
1871
  }
@@ -641,6 +1906,22 @@ async function executeToolCall(
641
1906
  }
642
1907
  }
643
1908
 
1909
+ // Gap 12: Mask secrets in tool output before forwarding to callbacks/history
1910
+ if (!result.isError && result.output) {
1911
+ result = { ...result, output: maskSecrets(result.output) };
1912
+ }
1913
+
1914
+ // PostToolUse hooks — fire-and-forget (audit, auto-format, etc.)
1915
+ if (hookEngine) {
1916
+ await runPostToolHooks(hookEngine, {
1917
+ ...hookContext,
1918
+ result: {
1919
+ output: result.isError ? (result.error ?? '') : result.output,
1920
+ isError: result.isError,
1921
+ },
1922
+ });
1923
+ }
1924
+
644
1925
  // Notify end
645
1926
  if (onEnd) {
646
1927
  onEnd(callInfo, result);