@build-astron-co/nimbus 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (469) hide show
  1. package/bin/nimbus +26 -10
  2. package/bin/nimbus.cmd +41 -0
  3. package/bin/nimbus.mjs +70 -0
  4. package/completions/nimbus.bash +38 -0
  5. package/completions/nimbus.fish +48 -0
  6. package/completions/nimbus.zsh +81 -0
  7. package/dist/src/agent/compaction-agent.js +215 -0
  8. package/dist/src/agent/context-manager.js +385 -0
  9. package/dist/src/agent/context.js +322 -0
  10. package/dist/src/agent/deploy-preview.js +395 -0
  11. package/dist/src/agent/expand-files.js +95 -0
  12. package/dist/src/agent/index.js +18 -0
  13. package/dist/src/agent/loop.js +1535 -0
  14. package/dist/src/agent/modes.js +347 -0
  15. package/dist/src/agent/permissions.js +396 -0
  16. package/dist/src/agent/subagents/base.js +67 -0
  17. package/dist/src/agent/subagents/cost.js +45 -0
  18. package/dist/src/agent/subagents/explore.js +36 -0
  19. package/dist/src/agent/subagents/general.js +41 -0
  20. package/dist/src/agent/subagents/index.js +88 -0
  21. package/dist/src/agent/subagents/infra.js +52 -0
  22. package/dist/src/agent/subagents/security.js +60 -0
  23. package/dist/src/agent/system-prompt.js +860 -0
  24. package/dist/src/app.js +152 -0
  25. package/dist/src/audit/activity-log.js +209 -0
  26. package/dist/src/audit/compliance-checker.js +419 -0
  27. package/dist/src/audit/cost-tracker.js +231 -0
  28. package/dist/src/audit/index.js +10 -0
  29. package/dist/src/audit/security-scanner.js +490 -0
  30. package/dist/src/auth/guard.js +64 -0
  31. package/dist/src/auth/index.js +19 -0
  32. package/dist/src/auth/keychain.js +79 -0
  33. package/dist/src/auth/oauth.js +389 -0
  34. package/dist/src/auth/providers.js +415 -0
  35. package/dist/src/auth/sso.js +87 -0
  36. package/dist/src/auth/store.js +424 -0
  37. package/dist/src/auth/types.js +5 -0
  38. package/dist/src/cli/index.js +8 -0
  39. package/dist/src/cli/init.js +1048 -0
  40. package/dist/src/cli/openapi-spec.js +346 -0
  41. package/dist/src/cli/run.js +505 -0
  42. package/dist/src/cli/serve-auth.js +56 -0
  43. package/dist/src/cli/serve.js +432 -0
  44. package/dist/src/cli/web.js +50 -0
  45. package/dist/src/cli.js +1574 -0
  46. package/dist/src/clients/core-engine-client.js +156 -0
  47. package/dist/src/clients/enterprise-client.js +246 -0
  48. package/dist/src/clients/generator-client.js +219 -0
  49. package/dist/src/clients/git-client.js +367 -0
  50. package/dist/src/clients/github-client.js +229 -0
  51. package/dist/src/clients/helm-client.js +299 -0
  52. package/dist/src/clients/index.js +18 -0
  53. package/dist/src/clients/k8s-client.js +270 -0
  54. package/dist/src/clients/llm-client.js +119 -0
  55. package/dist/src/clients/rest-client.js +104 -0
  56. package/dist/src/clients/service-discovery.js +35 -0
  57. package/dist/src/clients/terraform-client.js +302 -0
  58. package/dist/src/clients/tools-client.js +1227 -0
  59. package/dist/src/clients/ws-client.js +93 -0
  60. package/dist/src/commands/alias.js +91 -0
  61. package/dist/src/commands/analyze/index.js +313 -0
  62. package/dist/src/commands/apply/helm.js +375 -0
  63. package/dist/src/commands/apply/index.js +176 -0
  64. package/dist/src/commands/apply/k8s.js +350 -0
  65. package/dist/src/commands/apply/terraform.js +465 -0
  66. package/dist/src/commands/ask.js +137 -0
  67. package/dist/src/commands/audit/index.js +322 -0
  68. package/dist/src/commands/auth-cloud.js +345 -0
  69. package/dist/src/commands/auth-list.js +112 -0
  70. package/dist/src/commands/auth-profile.js +104 -0
  71. package/dist/src/commands/auth-refresh.js +161 -0
  72. package/dist/src/commands/auth-status.js +122 -0
  73. package/dist/src/commands/aws/ec2.js +402 -0
  74. package/dist/src/commands/aws/iam.js +304 -0
  75. package/dist/src/commands/aws/index.js +108 -0
  76. package/dist/src/commands/aws/lambda.js +317 -0
  77. package/dist/src/commands/aws/rds.js +345 -0
  78. package/dist/src/commands/aws/s3.js +346 -0
  79. package/dist/src/commands/aws/vpc.js +302 -0
  80. package/dist/src/commands/aws-discover.js +413 -0
  81. package/dist/src/commands/aws-terraform.js +618 -0
  82. package/dist/src/commands/azure/aks.js +305 -0
  83. package/dist/src/commands/azure/functions.js +200 -0
  84. package/dist/src/commands/azure/index.js +93 -0
  85. package/dist/src/commands/azure/storage.js +378 -0
  86. package/dist/src/commands/azure/vm.js +291 -0
  87. package/dist/src/commands/billing/index.js +224 -0
  88. package/dist/src/commands/chat.js +259 -0
  89. package/dist/src/commands/completions.js +255 -0
  90. package/dist/src/commands/config.js +291 -0
  91. package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
  92. package/dist/src/commands/cost/estimator.js +73 -0
  93. package/dist/src/commands/cost/index.js +625 -0
  94. package/dist/src/commands/cost/parsers/terraform.js +234 -0
  95. package/dist/src/commands/cost/parsers/types.js +4 -0
  96. package/dist/src/commands/cost/pricing/aws.js +501 -0
  97. package/dist/src/commands/cost/pricing/azure.js +462 -0
  98. package/dist/src/commands/cost/pricing/gcp.js +359 -0
  99. package/dist/src/commands/cost/pricing/index.js +24 -0
  100. package/dist/src/commands/demo.js +196 -0
  101. package/dist/src/commands/deploy.js +215 -0
  102. package/dist/src/commands/doctor.js +1291 -0
  103. package/dist/src/commands/drift/index.js +674 -0
  104. package/dist/src/commands/explain.js +235 -0
  105. package/dist/src/commands/export.js +120 -0
  106. package/dist/src/commands/feedback.js +319 -0
  107. package/dist/src/commands/fix.js +263 -0
  108. package/dist/src/commands/fs/index.js +338 -0
  109. package/dist/src/commands/gcp/compute.js +266 -0
  110. package/dist/src/commands/gcp/functions.js +221 -0
  111. package/dist/src/commands/gcp/gke.js +357 -0
  112. package/dist/src/commands/gcp/iam.js +295 -0
  113. package/dist/src/commands/gcp/index.js +105 -0
  114. package/dist/src/commands/gcp/storage.js +232 -0
  115. package/dist/src/commands/generate-helm.js +1026 -0
  116. package/dist/src/commands/generate-k8s.js +1263 -0
  117. package/dist/src/commands/generate-terraform.js +1058 -0
  118. package/dist/src/commands/gh/index.js +663 -0
  119. package/dist/src/commands/git/index.js +1208 -0
  120. package/dist/src/commands/helm/index.js +985 -0
  121. package/dist/src/commands/help.js +639 -0
  122. package/dist/src/commands/history.js +120 -0
  123. package/dist/src/commands/import.js +782 -0
  124. package/dist/src/commands/incident.js +144 -0
  125. package/dist/src/commands/index.js +109 -0
  126. package/dist/src/commands/init.js +955 -0
  127. package/dist/src/commands/k8s/index.js +979 -0
  128. package/dist/src/commands/login.js +588 -0
  129. package/dist/src/commands/logout.js +61 -0
  130. package/dist/src/commands/logs.js +160 -0
  131. package/dist/src/commands/onboarding.js +382 -0
  132. package/dist/src/commands/pipeline.js +153 -0
  133. package/dist/src/commands/plan/display.js +216 -0
  134. package/dist/src/commands/plan/index.js +525 -0
  135. package/dist/src/commands/plugin.js +325 -0
  136. package/dist/src/commands/preview.js +356 -0
  137. package/dist/src/commands/profile.js +297 -0
  138. package/dist/src/commands/questionnaire.js +1021 -0
  139. package/dist/src/commands/resume.js +35 -0
  140. package/dist/src/commands/rollback.js +259 -0
  141. package/dist/src/commands/rollout.js +74 -0
  142. package/dist/src/commands/runbook.js +307 -0
  143. package/dist/src/commands/schedule.js +202 -0
  144. package/dist/src/commands/status.js +213 -0
  145. package/dist/src/commands/team/index.js +309 -0
  146. package/dist/src/commands/team-context.js +200 -0
  147. package/dist/src/commands/template.js +204 -0
  148. package/dist/src/commands/tf/index.js +989 -0
  149. package/dist/src/commands/upgrade.js +515 -0
  150. package/dist/src/commands/usage/index.js +118 -0
  151. package/dist/src/commands/version.js +145 -0
  152. package/dist/src/commands/watch.js +127 -0
  153. package/dist/src/compat/index.js +2 -0
  154. package/dist/src/compat/runtime.js +10 -0
  155. package/dist/src/compat/sqlite.js +144 -0
  156. package/dist/src/config/index.js +6 -0
  157. package/dist/src/config/manager.js +469 -0
  158. package/dist/src/config/mode-store.js +57 -0
  159. package/dist/src/config/profiles.js +66 -0
  160. package/dist/src/config/safety-policy.js +251 -0
  161. package/dist/src/config/schema.js +107 -0
  162. package/dist/src/config/types.js +311 -0
  163. package/dist/src/config/workspace-state.js +38 -0
  164. package/dist/src/context/context-db.js +138 -0
  165. package/dist/src/demo/index.js +295 -0
  166. package/dist/src/demo/scenarios/full-journey.js +226 -0
  167. package/dist/src/demo/scenarios/getting-started.js +124 -0
  168. package/dist/src/demo/scenarios/helm-release.js +334 -0
  169. package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
  170. package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
  171. package/dist/src/demo/types.js +6 -0
  172. package/dist/src/engine/cost-estimator.js +334 -0
  173. package/dist/src/engine/diagram-generator.js +192 -0
  174. package/dist/src/engine/drift-detector.js +688 -0
  175. package/dist/src/engine/executor.js +832 -0
  176. package/dist/src/engine/index.js +39 -0
  177. package/dist/src/engine/orchestrator.js +436 -0
  178. package/dist/src/engine/planner.js +616 -0
  179. package/dist/src/engine/safety.js +609 -0
  180. package/dist/src/engine/verifier.js +664 -0
  181. package/dist/src/enterprise/audit.js +241 -0
  182. package/dist/src/enterprise/auth.js +189 -0
  183. package/dist/src/enterprise/billing.js +512 -0
  184. package/dist/src/enterprise/index.js +16 -0
  185. package/dist/src/enterprise/teams.js +315 -0
  186. package/dist/src/generator/best-practices.js +1375 -0
  187. package/dist/src/generator/helm.js +495 -0
  188. package/dist/src/generator/index.js +11 -0
  189. package/dist/src/generator/intent-parser.js +420 -0
  190. package/dist/src/generator/kubernetes.js +773 -0
  191. package/dist/src/generator/terraform.js +1472 -0
  192. package/dist/src/history/index.js +6 -0
  193. package/dist/src/history/manager.js +199 -0
  194. package/dist/src/history/types.js +6 -0
  195. package/dist/src/hooks/config.js +318 -0
  196. package/dist/src/hooks/engine.js +317 -0
  197. package/dist/src/hooks/index.js +2 -0
  198. package/dist/src/llm/auth-bridge.js +157 -0
  199. package/dist/src/llm/circuit-breaker.js +116 -0
  200. package/dist/src/llm/config-loader.js +172 -0
  201. package/dist/src/llm/cost-calculator.js +137 -0
  202. package/dist/src/llm/index.js +7 -0
  203. package/dist/src/llm/model-aliases.js +99 -0
  204. package/dist/src/llm/provider-registry.js +57 -0
  205. package/dist/src/llm/providers/anthropic.js +430 -0
  206. package/dist/src/llm/providers/bedrock.js +409 -0
  207. package/dist/src/llm/providers/google.js +344 -0
  208. package/dist/src/llm/providers/ollama.js +661 -0
  209. package/dist/src/llm/providers/openai-compatible.js +289 -0
  210. package/dist/src/llm/providers/openai.js +284 -0
  211. package/dist/src/llm/providers/openrouter.js +293 -0
  212. package/dist/src/llm/router.js +844 -0
  213. package/dist/src/llm/types.js +69 -0
  214. package/dist/src/lsp/client.js +239 -0
  215. package/dist/src/lsp/languages.js +95 -0
  216. package/dist/src/lsp/manager.js +243 -0
  217. package/dist/src/mcp/client.js +289 -0
  218. package/dist/src/mcp/index.js +5 -0
  219. package/dist/src/mcp/manager.js +113 -0
  220. package/dist/src/nimbus.js +212 -0
  221. package/dist/src/plugins/index.js +13 -0
  222. package/dist/src/plugins/loader.js +280 -0
  223. package/dist/src/plugins/manager.js +282 -0
  224. package/dist/src/plugins/types.js +23 -0
  225. package/dist/src/scanners/cicd-scanner.js +230 -0
  226. package/dist/src/scanners/cloud-scanner.js +415 -0
  227. package/dist/src/scanners/framework-scanner.js +430 -0
  228. package/dist/src/scanners/iac-scanner.js +350 -0
  229. package/dist/src/scanners/index.js +454 -0
  230. package/dist/src/scanners/language-scanner.js +258 -0
  231. package/dist/src/scanners/package-manager-scanner.js +252 -0
  232. package/dist/src/scanners/types.js +6 -0
  233. package/dist/src/sessions/manager.js +395 -0
  234. package/dist/src/sessions/types.js +4 -0
  235. package/dist/src/sharing/sync.js +238 -0
  236. package/dist/src/sharing/viewer.js +131 -0
  237. package/dist/src/snapshots/index.js +1 -0
  238. package/dist/src/snapshots/manager.js +432 -0
  239. package/dist/src/state/artifacts.js +94 -0
  240. package/dist/src/state/audit.js +73 -0
  241. package/dist/src/state/billing.js +126 -0
  242. package/dist/src/state/checkpoints.js +81 -0
  243. package/dist/src/state/config.js +58 -0
  244. package/dist/src/state/conversations.js +7 -0
  245. package/dist/src/state/credentials.js +96 -0
  246. package/dist/src/state/db.js +53 -0
  247. package/dist/src/state/index.js +23 -0
  248. package/dist/src/state/messages.js +76 -0
  249. package/dist/src/state/projects.js +92 -0
  250. package/dist/src/state/schema.js +233 -0
  251. package/dist/src/state/sessions.js +79 -0
  252. package/dist/src/state/teams.js +131 -0
  253. package/dist/src/telemetry.js +91 -0
  254. package/dist/src/tools/aws-ops.js +747 -0
  255. package/dist/src/tools/azure-ops.js +491 -0
  256. package/dist/src/tools/file-ops.js +451 -0
  257. package/dist/src/tools/gcp-ops.js +559 -0
  258. package/dist/src/tools/git-ops.js +557 -0
  259. package/dist/src/tools/github-ops.js +460 -0
  260. package/dist/src/tools/helm-ops.js +634 -0
  261. package/dist/src/tools/index.js +16 -0
  262. package/dist/src/tools/k8s-ops.js +579 -0
  263. package/dist/src/tools/schemas/converter.js +129 -0
  264. package/dist/src/tools/schemas/devops.js +3319 -0
  265. package/dist/src/tools/schemas/index.js +19 -0
  266. package/dist/src/tools/schemas/standard.js +966 -0
  267. package/dist/src/tools/schemas/types.js +409 -0
  268. package/dist/src/tools/spawn-exec.js +109 -0
  269. package/dist/src/tools/terraform-ops.js +627 -0
  270. package/dist/src/types/config.js +1 -0
  271. package/dist/src/types/drift.js +4 -0
  272. package/dist/src/types/enterprise.js +5 -0
  273. package/dist/src/types/index.js +14 -0
  274. package/dist/src/types/plan.js +1 -0
  275. package/dist/src/types/request.js +1 -0
  276. package/dist/src/types/response.js +1 -0
  277. package/dist/src/types/service.js +1 -0
  278. package/dist/src/ui/App.js +1672 -0
  279. package/dist/src/ui/DeployPreview.js +60 -0
  280. package/dist/src/ui/FileDiffModal.js +108 -0
  281. package/dist/src/ui/Header.js +46 -0
  282. package/dist/src/ui/HelpModal.js +9 -0
  283. package/dist/src/ui/InputBox.js +408 -0
  284. package/dist/src/ui/MessageList.js +795 -0
  285. package/dist/src/ui/PermissionPrompt.js +72 -0
  286. package/dist/src/ui/StatusBar.js +109 -0
  287. package/dist/src/ui/TerminalPane.js +31 -0
  288. package/dist/src/ui/ToolCallDisplay.js +303 -0
  289. package/dist/src/ui/TreePane.js +83 -0
  290. package/dist/src/ui/chat-ui.js +721 -0
  291. package/dist/src/ui/index.js +11 -0
  292. package/dist/src/ui/ink/index.js +1325 -0
  293. package/dist/src/ui/streaming.js +137 -0
  294. package/dist/src/ui/theme.js +78 -0
  295. package/dist/src/ui/types.js +7 -0
  296. package/dist/src/utils/analytics.js +61 -0
  297. package/dist/src/utils/cost-warning.js +25 -0
  298. package/dist/src/utils/env.js +42 -0
  299. package/dist/src/utils/errors.js +54 -0
  300. package/dist/src/utils/event-bus.js +22 -0
  301. package/dist/src/utils/index.js +16 -0
  302. package/dist/src/utils/logger.js +150 -0
  303. package/dist/src/utils/rate-limiter.js +90 -0
  304. package/dist/src/utils/service-auth.js +36 -0
  305. package/dist/src/utils/validation.js +39 -0
  306. package/dist/src/version.js +3 -0
  307. package/dist/src/watcher/index.js +192 -0
  308. package/dist/src/wizard/approval.js +275 -0
  309. package/dist/src/wizard/index.js +13 -0
  310. package/dist/src/wizard/prompts.js +273 -0
  311. package/dist/src/wizard/types.js +4 -0
  312. package/dist/src/wizard/ui.js +453 -0
  313. package/dist/src/wizard/wizard.js +227 -0
  314. package/package.json +31 -23
  315. package/src/__tests__/alias.test.ts +133 -0
  316. package/src/__tests__/app.test.ts +1 -1
  317. package/src/__tests__/audit.test.ts +1 -1
  318. package/src/__tests__/circuit-breaker.test.ts +1 -1
  319. package/src/__tests__/cli-run.test.ts +237 -1
  320. package/src/__tests__/compat-sqlite.test.ts +68 -0
  321. package/src/__tests__/context-manager.test.ts +131 -1
  322. package/src/__tests__/context.test.ts +1 -1
  323. package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
  324. package/src/__tests__/doctor.test.ts +48 -0
  325. package/src/__tests__/enterprise.test.ts +1 -1
  326. package/src/__tests__/export.test.ts +236 -0
  327. package/src/__tests__/gap-11-18-20.test.ts +958 -0
  328. package/src/__tests__/generator.test.ts +1 -1
  329. package/src/__tests__/helm-streaming.test.ts +127 -0
  330. package/src/__tests__/hooks.test.ts +1 -1
  331. package/src/__tests__/incident.test.ts +179 -0
  332. package/src/__tests__/init.test.ts +55 -4
  333. package/src/__tests__/intent-parser.test.ts +1 -1
  334. package/src/__tests__/llm-router.test.ts +1 -1
  335. package/src/__tests__/logs.test.ts +107 -0
  336. package/src/__tests__/loop-errors.test.ts +244 -0
  337. package/src/__tests__/lsp.test.ts +1 -1
  338. package/src/__tests__/modes.test.ts +1 -1
  339. package/src/__tests__/perf-optimizations.test.ts +847 -0
  340. package/src/__tests__/permissions.test.ts +1 -1
  341. package/src/__tests__/pipeline.test.ts +50 -0
  342. package/src/__tests__/polish-phase3.test.ts +340 -0
  343. package/src/__tests__/profile.test.ts +237 -0
  344. package/src/__tests__/rollback.test.ts +83 -0
  345. package/src/__tests__/runbook.test.ts +219 -0
  346. package/src/__tests__/schedule.test.ts +206 -0
  347. package/src/__tests__/serve.test.ts +1 -1
  348. package/src/__tests__/sessions.test.ts +96 -1
  349. package/src/__tests__/sharing.test.ts +53 -1
  350. package/src/__tests__/snapshots.test.ts +1 -1
  351. package/src/__tests__/standalone-migration.test.ts +199 -0
  352. package/src/__tests__/state-db.test.ts +1 -1
  353. package/src/__tests__/status.test.ts +158 -0
  354. package/src/__tests__/stream-with-tools.test.ts +71 -25
  355. package/src/__tests__/subagents.test.ts +1 -1
  356. package/src/__tests__/system-prompt.test.ts +82 -3
  357. package/src/__tests__/terminal-gap-v2.test.ts +395 -0
  358. package/src/__tests__/terminal-parity.test.ts +393 -0
  359. package/src/__tests__/tf-apply.test.ts +187 -0
  360. package/src/__tests__/tool-converter.test.ts +1 -1
  361. package/src/__tests__/tool-schemas.test.ts +209 -4
  362. package/src/__tests__/tools.test.ts +4 -3
  363. package/src/__tests__/version-json.test.ts +184 -0
  364. package/src/__tests__/version.test.ts +1 -1
  365. package/src/__tests__/watch.test.ts +129 -0
  366. package/src/agent/compaction-agent.ts +40 -1
  367. package/src/agent/context-manager.ts +67 -3
  368. package/src/agent/deploy-preview.ts +62 -1
  369. package/src/agent/expand-files.ts +108 -0
  370. package/src/agent/loop.ts +1312 -31
  371. package/src/agent/permissions.ts +51 -4
  372. package/src/agent/system-prompt.ts +573 -19
  373. package/src/app.ts +58 -0
  374. package/src/audit/security-scanner.ts +45 -0
  375. package/src/auth/keychain.ts +82 -0
  376. package/src/auth/oauth.ts +15 -5
  377. package/src/cli/init.ts +378 -5
  378. package/src/cli/run.ts +407 -16
  379. package/src/cli/serve.ts +78 -1
  380. package/src/cli/web.ts +10 -6
  381. package/src/cli.ts +312 -1
  382. package/src/clients/service-discovery.ts +30 -25
  383. package/src/commands/alias.ts +100 -0
  384. package/src/commands/audit/index.ts +121 -2
  385. package/src/commands/auth-cloud.ts +113 -0
  386. package/src/commands/auth-refresh.ts +187 -0
  387. package/src/commands/aws-discover.ts +144 -251
  388. package/src/commands/aws-terraform.ts +68 -118
  389. package/src/commands/chat.ts +9 -3
  390. package/src/commands/completions.ts +268 -0
  391. package/src/commands/config.ts +26 -0
  392. package/src/commands/cost/index.ts +218 -2
  393. package/src/commands/deploy.ts +260 -0
  394. package/src/commands/doctor.ts +744 -152
  395. package/src/commands/drift/index.ts +371 -23
  396. package/src/commands/export.ts +146 -0
  397. package/src/commands/generate-k8s.ts +9 -61
  398. package/src/commands/generate-terraform.ts +191 -449
  399. package/src/commands/help.ts +212 -36
  400. package/src/commands/history.ts +8 -1
  401. package/src/commands/incident.ts +166 -0
  402. package/src/commands/init.ts +5 -0
  403. package/src/commands/login.ts +86 -1
  404. package/src/commands/logs.ts +167 -0
  405. package/src/commands/onboarding.ts +211 -34
  406. package/src/commands/pipeline.ts +186 -0
  407. package/src/commands/plugin.ts +398 -0
  408. package/src/commands/profile.ts +342 -0
  409. package/src/commands/questionnaire.ts +0 -98
  410. package/src/commands/resume.ts +26 -34
  411. package/src/commands/rollback.ts +315 -0
  412. package/src/commands/rollout.ts +88 -0
  413. package/src/commands/runbook.ts +346 -0
  414. package/src/commands/schedule.ts +236 -0
  415. package/src/commands/status.ts +252 -0
  416. package/src/commands/team-context.ts +220 -0
  417. package/src/commands/template.ts +58 -57
  418. package/src/commands/tf/index.ts +70 -11
  419. package/src/commands/upgrade.ts +57 -0
  420. package/src/commands/version.ts +54 -50
  421. package/src/commands/watch.ts +153 -0
  422. package/src/compat/runtime.ts +1 -1
  423. package/src/compat/sqlite.ts +75 -5
  424. package/src/config/mode-store.ts +62 -0
  425. package/src/config/profiles.ts +84 -0
  426. package/src/config/types.ts +83 -1
  427. package/src/config/workspace-state.ts +53 -0
  428. package/src/engine/cost-estimator.ts +52 -10
  429. package/src/engine/executor.ts +33 -2
  430. package/src/engine/planner.ts +68 -1
  431. package/src/generator/terraform.ts +8 -0
  432. package/src/history/manager.ts +2 -74
  433. package/src/hooks/engine.ts +5 -4
  434. package/src/llm/cost-calculator.ts +2 -2
  435. package/src/llm/providers/anthropic.ts +50 -21
  436. package/src/llm/router.ts +76 -7
  437. package/src/lsp/languages.ts +3 -0
  438. package/src/lsp/manager.ts +21 -5
  439. package/src/nimbus.ts +37 -18
  440. package/src/sessions/manager.ts +108 -1
  441. package/src/sharing/sync.ts +4 -0
  442. package/src/sharing/viewer.ts +66 -0
  443. package/src/tools/file-ops.ts +22 -0
  444. package/src/tools/schemas/devops.ts +3007 -117
  445. package/src/tools/schemas/standard.ts +5 -1
  446. package/src/tools/schemas/types.ts +31 -1
  447. package/src/tools/spawn-exec.ts +148 -0
  448. package/src/ui/App.tsx +1183 -66
  449. package/src/ui/DeployPreview.tsx +62 -57
  450. package/src/ui/FileDiffModal.tsx +162 -0
  451. package/src/ui/Header.tsx +87 -24
  452. package/src/ui/HelpModal.tsx +57 -0
  453. package/src/ui/InputBox.tsx +163 -10
  454. package/src/ui/MessageList.tsx +487 -40
  455. package/src/ui/PermissionPrompt.tsx +17 -5
  456. package/src/ui/StatusBar.tsx +122 -3
  457. package/src/ui/TerminalPane.tsx +84 -0
  458. package/src/ui/ToolCallDisplay.tsx +252 -18
  459. package/src/ui/TreePane.tsx +132 -0
  460. package/src/ui/chat-ui.ts +41 -44
  461. package/src/ui/ink/index.ts +771 -38
  462. package/src/ui/streaming.ts +1 -1
  463. package/src/ui/theme.ts +104 -0
  464. package/src/ui/types.ts +18 -0
  465. package/src/version.ts +1 -1
  466. package/src/watcher/index.ts +66 -15
  467. package/src/wizard/types.ts +1 -0
  468. package/src/wizard/ui.ts +1 -1
  469. package/tsconfig.json +2 -2
@@ -0,0 +1,1325 @@
1
+ /**
2
+ * Ink TUI Launcher
3
+ *
4
+ * Bridges the Ink-based App component with the core agent loop.
5
+ * This is the entry point for `nimbus chat --ui=ink` (and the default).
6
+ */
7
+ import React from 'react';
8
+ import { render } from 'ink';
9
+ import { App, AppErrorBoundary, } from '../App';
10
+ import { getAppContext } from '../../app';
11
+ import { runAgentLoop } from '../../agent/loop';
12
+ import { buildSystemPrompt } from '../../agent/system-prompt';
13
+ import { ContextManager } from '../../agent/context-manager';
14
+ import { SnapshotManager } from '../../snapshots/manager';
15
+ import { defaultToolRegistry } from '../../tools/schemas/types';
16
+ import { getTextContent } from '../../llm/types';
17
+ import { SessionManager } from '../../sessions/manager';
18
+ import { createPermissionState, checkPermission, approveForSession, approveActionForSession, } from '../../agent/permissions';
19
+ import { FileWatcher } from '../../watcher';
20
+ import { HookEngine } from '../../hooks/engine';
21
+ import { getLSPManager } from '../../lsp/manager';
22
+ import { DEVOPS_LANGUAGE_IDS } from '../../lsp/languages';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import { setTheme } from '../theme';
27
+ /**
28
+ * Launch the Ink-based interactive chat TUI.
29
+ *
30
+ * Renders the React/Ink `App` component and wires it to the core agent
31
+ * loop so that each user message triggers an agentic conversation turn.
32
+ */
33
+ export async function startInkChat(options = {}) {
34
+ const ctx = getAppContext();
35
+ if (!ctx) {
36
+ throw new Error('App not initialised. Call initApp() before startInkChat().');
37
+ }
38
+ // Gap 19: collect any startup warnings so they can be shown as system messages
39
+ let _startupWarnings = [];
40
+ try {
41
+ const { startupWarnings } = await import('../../app');
42
+ _startupWarnings = startupWarnings;
43
+ }
44
+ catch { /* non-critical */ }
45
+ // Gap 2: load theme from ~/.nimbus/config.yaml if present
46
+ try {
47
+ const configPath = join(homedir(), '.nimbus', 'config.yaml');
48
+ if (existsSync(configPath)) {
49
+ const configContent = readFileSync(configPath, 'utf-8');
50
+ const themeMatch = configContent.match(/^theme:\s*(\S+)/m);
51
+ if (themeMatch) {
52
+ setTheme(themeMatch[1]);
53
+ }
54
+ }
55
+ }
56
+ catch { /* non-critical */ }
57
+ // Use mutable refs so /model, /mode, and Tab changes propagate to the agent loop
58
+ let currentMode = options.mode ?? 'build';
59
+ let currentModel = options.model;
60
+ // Gap 7 & 10: live infra context discovered at startup
61
+ let currentInfraContext;
62
+ // C1: Load prior infra state from ~/.nimbus/infra-state.json before discovery
63
+ const infraStatePath = join(homedir(), '.nimbus', 'infra-state.json');
64
+ let priorInfraState;
65
+ try {
66
+ if (existsSync(infraStatePath)) {
67
+ const raw = readFileSync(infraStatePath, 'utf-8');
68
+ priorInfraState = JSON.parse(raw);
69
+ }
70
+ }
71
+ catch { /* non-critical */ }
72
+ // H6: Load persisted workspace state as baseline (fresh discovery will override below)
73
+ try {
74
+ const { loadWorkspaceState } = await import('../../config/workspace-state');
75
+ const storedWorkspace = loadWorkspaceState(process.cwd());
76
+ if (!currentInfraContext && Object.keys(storedWorkspace).length > 0) {
77
+ currentInfraContext = storedWorkspace;
78
+ }
79
+ }
80
+ catch { /* non-critical */ }
81
+ const contextManager = new ContextManager({ model: currentModel });
82
+ const snapshotManager = new SnapshotManager({ projectDir: process.cwd() });
83
+ const lspManager = getLSPManager(process.cwd(), { enabledLanguages: DEVOPS_LANGUAGE_IDS });
84
+ // Concurrent message guard: prevent overlapping agent loop runs
85
+ let isRunning = false;
86
+ // Context window warning: warn once per session at 70% usage
87
+ let contextWarningShown = false;
88
+ // Eagerly load NIMBUS.md for explicit pass-through to the agent loop.
89
+ // On the first run (no NIMBUS.md found), auto-run `nimbus init --quiet`
90
+ // to generate one with detected project context.
91
+ let nimbusInstructions;
92
+ const nimbusMdPaths = [
93
+ join(process.cwd(), 'NIMBUS.md'),
94
+ join(process.cwd(), '.nimbus', 'NIMBUS.md'),
95
+ ];
96
+ const foundNimbusMd = nimbusMdPaths.find(p => existsSync(p));
97
+ if (foundNimbusMd) {
98
+ try {
99
+ nimbusInstructions = readFileSync(foundNimbusMd, 'utf-8');
100
+ }
101
+ catch {
102
+ /* skip */
103
+ }
104
+ }
105
+ else if (!options.resumeSessionId) {
106
+ // Fresh session with no NIMBUS.md — silently auto-generate one
107
+ try {
108
+ const { runInit } = await import('../../cli/init');
109
+ const result = await runInit({ cwd: process.cwd(), quiet: true });
110
+ // Load the freshly generated NIMBUS.md
111
+ if (result.nimbusmdPath && existsSync(result.nimbusmdPath)) {
112
+ nimbusInstructions = readFileSync(result.nimbusmdPath, 'utf-8');
113
+ }
114
+ }
115
+ catch {
116
+ /* init failure is non-critical — proceed without project context */
117
+ }
118
+ }
119
+ // G4: If NIMBUS.md is still missing after auto-init attempt, show a prominent banner
120
+ const isNewSessionEarly = !options.resumeSessionId;
121
+ const nimbusMdMissing = !nimbusInstructions;
122
+ // initialMessages array will be populated later; we track the banner flag here
123
+ const showNimbusMdBanner = nimbusMdMissing && isNewSessionEarly;
124
+ // Initialize hook engine with project dir (loads .nimbus/hooks.yaml if present)
125
+ const hookEngine = new HookEngine(process.cwd());
126
+ // Start filesystem watcher for external change awareness
127
+ const watcher = new FileWatcher(process.cwd());
128
+ watcher.start();
129
+ // NIMBUS.md live reload (M10): watch for changes to NIMBUS.md mid-session
130
+ // M5: Also notify on DevOps file changes (debounced 30s per file)
131
+ const devopsChangeDebounce = new Map();
132
+ watcher.on('change', (changedPath) => {
133
+ if (changedPath.endsWith('NIMBUS.md')) {
134
+ try {
135
+ nimbusInstructions = readFileSync(changedPath, 'utf-8');
136
+ addMessage({
137
+ id: crypto.randomUUID(),
138
+ role: 'system',
139
+ content: '[md] NIMBUS.md reloaded — new instructions active for next turn.',
140
+ timestamp: new Date(),
141
+ });
142
+ }
143
+ catch {
144
+ /* ignore read errors */
145
+ }
146
+ }
147
+ // M5: Notify on DevOps file changes (debounced 30s per file)
148
+ const filePath = typeof changedPath === 'string' ? changedPath : changedPath?.path ?? '';
149
+ const isDevOps = /\.(tf|yaml|yml)$|Dockerfile|docker-compose/i.test(filePath);
150
+ if (isDevOps) {
151
+ const existing = devopsChangeDebounce.get(filePath);
152
+ if (existing)
153
+ clearTimeout(existing);
154
+ const timer = setTimeout(() => {
155
+ devopsChangeDebounce.delete(filePath);
156
+ const relPath = filePath.replace(process.cwd() + '/', '');
157
+ const hint = relPath.endsWith('.tf') ? '/plan' : relPath.includes('yaml') ? '/plan' : '/init';
158
+ addMessage({
159
+ id: crypto.randomUUID(),
160
+ role: 'system',
161
+ content: `[~] File changed: ${relPath} — type ${hint} to review drift impact`,
162
+ timestamp: new Date(),
163
+ });
164
+ }, 30000);
165
+ devopsChangeDebounce.set(filePath, timer);
166
+ }
167
+ });
168
+ // C4: Surface LSP unavailability as system messages so the user knows diagnostics are disabled
169
+ lspManager.on('lsp-unavailable', (lang, cmd) => {
170
+ addMessage({
171
+ id: crypto.randomUUID(),
172
+ role: 'system',
173
+ content: `[LSP] ${lang} server (${cmd}) not found — diagnostics disabled.`,
174
+ timestamp: new Date(),
175
+ });
176
+ });
177
+ // Create or resume a session for conversation persistence
178
+ let sessionManager = null;
179
+ let sessionId = null;
180
+ try {
181
+ sessionManager = SessionManager.getInstance();
182
+ if (options.resumeSessionId) {
183
+ // Resume an existing session
184
+ const existing = sessionManager.get(options.resumeSessionId);
185
+ if (existing) {
186
+ sessionId = existing.id;
187
+ sessionManager.resume(existing.id);
188
+ }
189
+ }
190
+ if (!sessionId) {
191
+ // Create a new session
192
+ const session = sessionManager.create({
193
+ name: `chat-${new Date().toISOString().slice(0, 16)}`,
194
+ mode: currentMode,
195
+ model: currentModel,
196
+ cwd: process.cwd(),
197
+ });
198
+ sessionId = session.id;
199
+ }
200
+ }
201
+ catch (sessionErr) {
202
+ // C5: Surface SQLite failure prominently in the TUI (not just stderr)
203
+ const errMsg = sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
204
+ const tuiWarning = `Session persistence unavailable: ${errMsg}. Chat history will NOT be saved this session. Fix: npm install better-sqlite3`;
205
+ _startupWarnings.push(tuiWarning);
206
+ process.stderr.write(`\x1b[33m Warning: ${tuiWarning}\x1b[0m\n`);
207
+ }
208
+ // Gap 7 & 10: discover live infra context at startup (best-effort, non-blocking)
209
+ try {
210
+ const { discoverInfraContext } = await import('../../cli/init');
211
+ currentInfraContext = await discoverInfraContext(process.cwd());
212
+ // C1: Merge with prior state (fresh discovery wins per-field)
213
+ if (priorInfraState) {
214
+ currentInfraContext = { ...priorInfraState, ...currentInfraContext };
215
+ }
216
+ // C1: Persist discovered infra state to ~/.nimbus/infra-state.json
217
+ if (currentInfraContext) {
218
+ try {
219
+ mkdirSync(join(homedir(), '.nimbus'), { recursive: true });
220
+ writeFileSync(infraStatePath, JSON.stringify(currentInfraContext, null, 2), 'utf-8');
221
+ }
222
+ catch { /* non-critical */ }
223
+ // H6: Also persist workspace state (terraform workspace + kubectl context) per cwd
224
+ try {
225
+ const { mergeWorkspaceState } = await import('../../config/workspace-state');
226
+ mergeWorkspaceState(process.cwd(), currentInfraContext ?? {});
227
+ }
228
+ catch { /* non-critical */ }
229
+ }
230
+ if (sessionManager && sessionId && currentInfraContext) {
231
+ try {
232
+ sessionManager.setInfraContext(sessionId, currentInfraContext);
233
+ }
234
+ catch { /* non-critical */ }
235
+ }
236
+ // C4: Set terminal window title with infra context
237
+ try {
238
+ const ctxLabel = [
239
+ currentInfraContext?.terraformWorkspace && `tf:${currentInfraContext.terraformWorkspace}`,
240
+ currentInfraContext?.kubectlContext && `k8s:${currentInfraContext.kubectlContext}`,
241
+ ].filter(Boolean).join(' | ') || 'nimbus';
242
+ process.stdout.write(`\x1b]0;nimbus -- ${ctxLabel}\x07`);
243
+ process.on('exit', () => process.stdout.write('\x1b]0;Terminal\x07'));
244
+ }
245
+ catch { /* non-critical */ }
246
+ }
247
+ catch { /* non-critical — infra discovery failure must never block startup */ }
248
+ // C3: Auto-generate NIMBUS.md if infra is detected but no NIMBUS.md exists
249
+ try {
250
+ const nimbusmdPath = join(process.cwd(), 'NIMBUS.md');
251
+ if (currentInfraContext && !existsSync(nimbusmdPath)) {
252
+ const hasTerraform = currentInfraContext.terraformWorkspace !== undefined
253
+ || existsSync(join(process.cwd(), 'main.tf'))
254
+ || existsSync(join(process.cwd(), 'terraform'));
255
+ const hasK8s = currentInfraContext.kubectlContext !== undefined;
256
+ const hasHelm = (currentInfraContext.helmReleases?.length ?? 0) > 0;
257
+ if (hasTerraform || hasK8s || hasHelm) {
258
+ const { generateNimbusMd, detectProject } = await import('../../cli/init');
259
+ const detection = detectProject(process.cwd());
260
+ const mdContent = generateNimbusMd(detection, process.cwd(), currentInfraContext);
261
+ writeFileSync(nimbusmdPath, mdContent, 'utf-8');
262
+ process.stderr.write('\x1b[32m [nimbus] Auto-generated NIMBUS.md from detected infra\x1b[0m\n');
263
+ }
264
+ }
265
+ }
266
+ catch { /* non-critical */ }
267
+ // Conversation history shared between turns.
268
+ // When resuming, restore saved conversation from the session.
269
+ let history = [];
270
+ if (options.resumeSessionId && sessionManager && sessionId) {
271
+ try {
272
+ const restored = sessionManager.loadConversation(sessionId);
273
+ if (restored.length > 0) {
274
+ history = restored;
275
+ }
276
+ }
277
+ catch {
278
+ // Restore is non-critical
279
+ }
280
+ // Gap 10: On resume, merge stored infra context with freshly discovered live context
281
+ try {
282
+ const storedInfra = sessionManager.getInfraContext(sessionId);
283
+ // Live context (already discovered above) takes precedence for mutable fields
284
+ currentInfraContext = { ...storedInfra, ...currentInfraContext };
285
+ }
286
+ catch { /* non-critical */ }
287
+ }
288
+ // G2 / C1: Build resume context summary message when resuming with infra context
289
+ // Also show when prior state was loaded (even on a new session) to confirm context continuity
290
+ const hasResumeContext = currentInfraContext && (currentInfraContext.kubectlContext || currentInfraContext.terraformWorkspace || currentInfraContext.awsAccount);
291
+ const showResumeBanner = (options.resumeSessionId || !!priorInfraState) && hasResumeContext;
292
+ const resumeContextMessage = showResumeBanner ? {
293
+ id: crypto.randomUUID(),
294
+ role: 'system',
295
+ content: [
296
+ options.resumeSessionId ? 'Resuming session -' : 'Resuming with:',
297
+ currentInfraContext.terraformWorkspace ? `tf:${currentInfraContext.terraformWorkspace}` : null,
298
+ currentInfraContext.kubectlContext ? `k8s:${currentInfraContext.kubectlContext}` : null,
299
+ currentInfraContext.awsAccount ? `aws:${currentInfraContext.awsAccount}` : null,
300
+ ].filter(Boolean).join(' | '),
301
+ timestamp: new Date(),
302
+ } : null;
303
+ // AbortController for cancellation (Ctrl+C / Escape)
304
+ let abortController = new AbortController();
305
+ // Permission session state (tracks ask-once approvals)
306
+ const permissionState = createPermissionState();
307
+ // Imperative API populated by the App component's onReady callback.
308
+ let api;
309
+ // Convenience accessors (safe to call before onReady fires).
310
+ const addMessage = (msg) => api?.addMessage(msg);
311
+ const updateMessage = (id, content) => api?.updateMessage(id, content);
312
+ const setProcessing = (v) => api?.setProcessing(v);
313
+ const updateSession = (patch) => api?.updateSession(patch);
314
+ const setToolCalls = (calls) => api?.setToolCalls(calls);
315
+ // Track active tool calls for UI updates
316
+ const activeToolCalls = new Map();
317
+ // Track the in-flight streaming message so we can update it incrementally
318
+ let streamingMessageId = null;
319
+ let streamingContent = '';
320
+ /**
321
+ * Derive a risk level from the tool's permission tier.
322
+ */
323
+ function getRiskLevel(tool) {
324
+ switch (tool.permissionTier) {
325
+ case 'auto_allow':
326
+ return 'low';
327
+ case 'ask_once':
328
+ return 'medium';
329
+ case 'always_ask':
330
+ return 'high';
331
+ case 'blocked':
332
+ return 'critical';
333
+ default:
334
+ return 'medium';
335
+ }
336
+ }
337
+ /**
338
+ * Prompt the user for permission via the Ink PermissionPrompt component.
339
+ * Uses the imperative API to render the prompt inside the TUI.
340
+ */
341
+ function promptPermission(tool, input) {
342
+ const toolInput = input && typeof input === 'object' ? input : {};
343
+ return new Promise(resolve => {
344
+ if (!api) {
345
+ // Imperative API not yet wired — deny by default
346
+ resolve('deny');
347
+ return;
348
+ }
349
+ api.requestPermission({
350
+ tool: tool.name,
351
+ input: toolInput,
352
+ riskLevel: getRiskLevel(tool),
353
+ onDecide: decision => {
354
+ // Map PermissionPrompt decisions to agent loop decisions
355
+ switch (decision) {
356
+ case 'approve':
357
+ resolve('allow');
358
+ break;
359
+ case 'session': {
360
+ approveForSession(tool, permissionState);
361
+ const action = toolInput.action;
362
+ if (typeof action === 'string') {
363
+ approveActionForSession(tool.name, action, permissionState);
364
+ }
365
+ resolve('allow');
366
+ break;
367
+ }
368
+ case 'approve_all':
369
+ approveForSession(tool, permissionState);
370
+ resolve('allow');
371
+ break;
372
+ case 'reject':
373
+ default:
374
+ resolve('deny');
375
+ break;
376
+ }
377
+ },
378
+ });
379
+ });
380
+ }
381
+ /**
382
+ * Determines whether a tool call requires a deploy preview confirmation in deploy mode.
383
+ * Covers terraform/kubectl/helm plus destructive bash cloud CLI commands.
384
+ */
385
+ function requiresDeployPreview(toolName, toolInput) {
386
+ if (['terraform', 'kubectl', 'helm'].includes(toolName))
387
+ return true;
388
+ if (toolName === 'docker') {
389
+ const action = String(toolInput.action ?? '');
390
+ return ['build', 'push', 'stop', 'compose-up', 'compose-down', 'rm', 'prune'].includes(action);
391
+ }
392
+ if (toolName === 'cloud_action') {
393
+ const action = String(toolInput.action ?? '');
394
+ return ['create', 'delete', 'stop'].includes(action);
395
+ }
396
+ if (toolName === 'cfn') {
397
+ const action = String(toolInput.action ?? '');
398
+ return ['create', 'update', 'delete', 'deploy'].includes(action);
399
+ }
400
+ if (toolName === 'bash') {
401
+ const cmd = String(toolInput.command ?? '');
402
+ return /\b(aws\s+\S+\s+delete|aws\s+ec2\s+terminate|gcloud\s+\S+\s+delete|az\s+\S+\s+delete|kubectl\s+delete)\b/.test(cmd);
403
+ }
404
+ return false;
405
+ }
406
+ /**
407
+ * Show the deploy preview modal and wait for user confirmation.
408
+ * Returns true if the user approves, false if they cancel.
409
+ */
410
+ function promptDeployPreview(tool, input) {
411
+ return new Promise(resolve => {
412
+ if (!api) {
413
+ resolve(true); // API not ready — allow by default
414
+ return;
415
+ }
416
+ const action = typeof input.action === 'string' ? input.action : 'apply';
417
+ const changeAction = action.includes('destroy') || action.includes('delete') ? 'destroy' : 'modify';
418
+ const preview = {
419
+ tool,
420
+ changes: [
421
+ {
422
+ action: changeAction,
423
+ resourceType: tool,
424
+ resourceName: typeof input.command === 'string' ? input.command : action,
425
+ details: typeof input.args === 'string' ? input.args : undefined,
426
+ },
427
+ ],
428
+ };
429
+ api.requestDeployPreview(preview, decision => {
430
+ resolve(decision === 'approve');
431
+ });
432
+ });
433
+ }
434
+ /**
435
+ * Handle a user message: run the agent loop and stream results back
436
+ * into the TUI.
437
+ */
438
+ // Track the timestamp of each turn so watcher can report changes since last turn
439
+ let lastTurnTimestamp = Date.now();
440
+ // M2: Track user message count for first-message session rename
441
+ let userMessageCount = 0;
442
+ /**
443
+ * GAP-20: Parse the ## Tool Timeouts section from NIMBUS.md.
444
+ * Each line has the format: tool_name: milliseconds
445
+ * Returns a Record<string, number> for passing to runAgentLoop as toolTimeouts.
446
+ */
447
+ function parseToolTimeouts(nimbusMd) {
448
+ const result = {};
449
+ const match = nimbusMd.match(/##\s+Tool Timeouts\s*\n([\s\S]*?)(?=##|$)/);
450
+ if (!match)
451
+ return result;
452
+ for (const line of match[1].split('\n')) {
453
+ const m = line.match(/^\s*([a-z_]+)\s*:\s*(\d+)\s*$/);
454
+ if (m)
455
+ result[m[1]] = parseInt(m[2], 10);
456
+ }
457
+ return result;
458
+ }
459
+ const onMessage = async (text) => {
460
+ // Gap 1: Prevent concurrent agent loop runs (would corrupt history)
461
+ if (isRunning) {
462
+ addMessage({
463
+ id: crypto.randomUUID(),
464
+ role: 'system',
465
+ content: 'Please wait — the agent is still processing the previous message.',
466
+ timestamp: new Date(),
467
+ });
468
+ return;
469
+ }
470
+ isRunning = true;
471
+ abortController = new AbortController();
472
+ // Track diff request index within this turn for progress display
473
+ let diffRequestIndex = 0;
474
+ // M2: Auto-rename session from first user message (semantic name)
475
+ userMessageCount++;
476
+ if (userMessageCount === 1 && sessionManager && sessionId) {
477
+ try {
478
+ const semanticName = text.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '');
479
+ if (semanticName)
480
+ sessionManager.rename(sessionId, semanticName);
481
+ }
482
+ catch { /* non-critical */ }
483
+ }
484
+ // Prepend external file change summary if any files changed since last turn
485
+ const changeSummary = watcher.getSummary(lastTurnTimestamp);
486
+ const enrichedText = changeSummary ? `[System: ${changeSummary}]\n\n${text}` : text;
487
+ watcher.clearChanges();
488
+ lastTurnTimestamp = Date.now();
489
+ try {
490
+ const result = await runAgentLoop(enrichedText, history, {
491
+ router: ctx.router,
492
+ toolRegistry: defaultToolRegistry,
493
+ mode: currentMode,
494
+ model: currentModel,
495
+ cwd: process.cwd(),
496
+ nimbusInstructions,
497
+ infraContext: currentInfraContext,
498
+ signal: abortController.signal,
499
+ contextManager,
500
+ snapshotManager,
501
+ lspManager,
502
+ hookEngine,
503
+ onText: chunk => {
504
+ // Stream text incrementally into the TUI
505
+ if (!streamingMessageId) {
506
+ streamingMessageId = crypto.randomUUID();
507
+ streamingContent = chunk;
508
+ addMessage({
509
+ id: streamingMessageId,
510
+ role: 'assistant',
511
+ content: streamingContent,
512
+ timestamp: new Date(),
513
+ });
514
+ }
515
+ else {
516
+ streamingContent += chunk;
517
+ updateMessage(streamingMessageId, streamingContent);
518
+ }
519
+ },
520
+ onToolCallStart: info => {
521
+ const toolCall = {
522
+ id: info.id,
523
+ name: info.name,
524
+ input: info.input && typeof info.input === 'object'
525
+ ? info.input
526
+ : {},
527
+ status: 'running',
528
+ startTime: info.startTime ?? Date.now(),
529
+ };
530
+ activeToolCalls.set(info.id, toolCall);
531
+ setToolCalls([...activeToolCalls.values()]);
532
+ },
533
+ onToolCallEnd: (info, toolResult) => {
534
+ const existing = activeToolCalls.get(info.id);
535
+ if (existing) {
536
+ existing.status = toolResult.isError ? 'failed' : 'completed';
537
+ existing.result = {
538
+ output: toolResult.isError ? (toolResult.error ?? '') : toolResult.output,
539
+ isError: toolResult.isError,
540
+ };
541
+ }
542
+ setToolCalls([...activeToolCalls.values()]);
543
+ // G6: Surface LSP diagnostics as visible TUI system messages
544
+ if (!toolResult.isError && typeof toolResult.output === 'string'
545
+ && toolResult.output.includes('LSP Diagnostics:')) {
546
+ const diagMatch = toolResult.output.match(/LSP Diagnostics:([\s\S]+?)(?:\n\n|$)/);
547
+ if (diagMatch) {
548
+ addMessage({
549
+ id: crypto.randomUUID(),
550
+ role: 'system',
551
+ content: `⚠ LSP: ${diagMatch[1].trim()}`,
552
+ timestamp: new Date(),
553
+ });
554
+ }
555
+ }
556
+ },
557
+ onToolOutputChunk: (toolId, chunk) => {
558
+ // Gap 1: stream live output into the running tool call's streamingOutput field
559
+ const existing = activeToolCalls.get(toolId);
560
+ if (existing) {
561
+ existing.streamingOutput = (existing.streamingOutput ?? '') + chunk;
562
+ setToolCalls([...activeToolCalls.values()]);
563
+ }
564
+ },
565
+ onUsage: (usage, costUSD) => {
566
+ // Update the TUI in real-time after each LLM turn
567
+ updateSession({
568
+ tokenCount: usage.totalTokens,
569
+ costUSD,
570
+ });
571
+ // Context window warning at 70% (H5)
572
+ // Use 200k as a reasonable default context window size
573
+ const CTX_MAX = 200_000;
574
+ if (!contextWarningShown && usage.totalTokens > 0) {
575
+ const ratio = usage.totalTokens / CTX_MAX;
576
+ if (ratio >= 0.70) {
577
+ contextWarningShown = true;
578
+ addMessage({
579
+ id: crypto.randomUUID(),
580
+ role: 'system',
581
+ content: `⚠ Context window at ${Math.round(ratio * 100)}% — consider /compact [focus] to preserve the most important context before it auto-compacts at 85%.`,
582
+ timestamp: new Date(),
583
+ });
584
+ }
585
+ }
586
+ // Track per-turn cost delta for /cost command
587
+ const turnCost = costUSD - previousTotalCost;
588
+ if (turnCost > 0) {
589
+ currentTurn++;
590
+ turnCostLog.push({
591
+ turn: currentTurn,
592
+ costUSD: turnCost,
593
+ tokens: usage.totalTokens,
594
+ });
595
+ previousTotalCost = costUSD;
596
+ }
597
+ },
598
+ onCompact: compactResult => {
599
+ addMessage({
600
+ id: crypto.randomUUID(),
601
+ role: 'system',
602
+ content: `Context auto-compacted: saved ${compactResult.savedTokens.toLocaleString()} tokens.`,
603
+ timestamp: new Date(),
604
+ });
605
+ },
606
+ checkPermission: async (tool, input) => {
607
+ const toolInput = input && typeof input === 'object' ? input : {};
608
+ // In deploy mode, show a preview confirmation before infra-mutating tools
609
+ if (currentMode === 'deploy' && requiresDeployPreview(tool.name, toolInput)) {
610
+ const approved = await promptDeployPreview(tool.name, toolInput);
611
+ if (!approved) {
612
+ return 'deny';
613
+ }
614
+ }
615
+ const decision = checkPermission(tool, input, permissionState);
616
+ if (decision === 'allow') {
617
+ return 'allow';
618
+ }
619
+ if (decision === 'block') {
620
+ return 'block';
621
+ }
622
+ // decision === 'ask': prompt the user
623
+ return promptPermission(tool, input);
624
+ },
625
+ requestFileDiff: (path, toolName, diff) => new Promise(resolve => {
626
+ if (!api) {
627
+ resolve('apply');
628
+ return;
629
+ }
630
+ diffRequestIndex++;
631
+ api.requestFileDiff(path, toolName, diff, resolve, diffRequestIndex);
632
+ }),
633
+ // GAP-20: Pass per-tool timeouts parsed from NIMBUS.md
634
+ toolTimeouts: nimbusInstructions ? parseToolTimeouts(nimbusInstructions) : undefined,
635
+ });
636
+ // Clear active tool calls now that the turn is complete
637
+ activeToolCalls.clear();
638
+ setToolCalls([]);
639
+ // Update history with the full conversation from this turn
640
+ history = result.messages;
641
+ // Persist conversation + stats to SQLite atomically
642
+ if (sessionManager && sessionId) {
643
+ try {
644
+ sessionManager.saveConversationAndStats(sessionId, history, {
645
+ tokenCount: result.usage.totalTokens,
646
+ costUSD: result.totalCost,
647
+ });
648
+ }
649
+ catch {
650
+ /* persistence is non-critical */
651
+ }
652
+ }
653
+ // Finalize the streamed assistant message with the complete content.
654
+ // If onText was never called (e.g., the response was only tool calls),
655
+ // add the final assistant message now.
656
+ const lastAssistantMsg = [...result.messages].reverse().find(m => m.role === 'assistant');
657
+ if (lastAssistantMsg) {
658
+ const finalContent = getTextContent(lastAssistantMsg.content);
659
+ if (streamingMessageId) {
660
+ // Update the streamed message with the final complete content
661
+ updateMessage(streamingMessageId, finalContent);
662
+ }
663
+ else {
664
+ // No streaming happened — add the message now
665
+ addMessage({
666
+ id: crypto.randomUUID(),
667
+ role: 'assistant',
668
+ content: finalContent,
669
+ timestamp: new Date(),
670
+ });
671
+ }
672
+ }
673
+ // Reset streaming state for the next turn
674
+ streamingMessageId = null;
675
+ streamingContent = '';
676
+ // Update session stats
677
+ updateSession({
678
+ tokenCount: result.usage.totalTokens,
679
+ costUSD: result.totalCost,
680
+ });
681
+ // (Session stats already persisted atomically above with saveConversationAndStats)
682
+ }
683
+ catch (err) {
684
+ const msg = err instanceof Error ? err.message : String(err);
685
+ addMessage({
686
+ id: crypto.randomUUID(),
687
+ role: 'system',
688
+ content: `Error: ${msg}`,
689
+ timestamp: new Date(),
690
+ });
691
+ // Reset streaming state on error too
692
+ streamingMessageId = null;
693
+ streamingContent = '';
694
+ }
695
+ finally {
696
+ isRunning = false;
697
+ setProcessing(false);
698
+ }
699
+ };
700
+ /**
701
+ * Handle abort (Ctrl+C / Escape while processing).
702
+ */
703
+ const onAbort = () => {
704
+ abortController.abort();
705
+ };
706
+ /**
707
+ * Handle /compact command.
708
+ */
709
+ const onCompact = async (focusArea) => {
710
+ const systemPrompt = buildSystemPrompt({
711
+ mode: currentMode,
712
+ tools: defaultToolRegistry.getAll(),
713
+ cwd: process.cwd(),
714
+ });
715
+ const toolTokens = defaultToolRegistry
716
+ .getAll()
717
+ .reduce((sum, t) => sum + Math.ceil(JSON.stringify(t).length / 4), 0);
718
+ if (!contextManager.shouldCompact(systemPrompt, history, toolTokens)) {
719
+ return null;
720
+ }
721
+ const { runCompaction } = await import('../../agent/compaction-agent');
722
+ const result = await runCompaction(history, contextManager, {
723
+ router: ctx.router,
724
+ focusArea,
725
+ });
726
+ history = result.messages;
727
+ return {
728
+ originalTokens: result.result.originalTokens,
729
+ compactedTokens: result.result.compactedTokens,
730
+ savedTokens: result.result.savedTokens,
731
+ };
732
+ };
733
+ /**
734
+ * Estimate token count using gpt-tokenizer (falls back to char/4).
735
+ */
736
+ let _encode = null;
737
+ let _encodeLoaded = false;
738
+ function estimateTokens(text) {
739
+ if (!_encodeLoaded) {
740
+ _encodeLoaded = true;
741
+ try {
742
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
743
+ _encode = require('gpt-tokenizer').encode;
744
+ }
745
+ catch {
746
+ /* fallback */
747
+ }
748
+ }
749
+ if (_encode) {
750
+ try {
751
+ return _encode(text).length;
752
+ }
753
+ catch {
754
+ /* fallback */
755
+ }
756
+ }
757
+ return Math.ceil(text.length / 4);
758
+ }
759
+ /**
760
+ * Handle /context command.
761
+ */
762
+ const onContext = () => {
763
+ const systemPrompt = buildSystemPrompt({
764
+ mode: currentMode,
765
+ tools: defaultToolRegistry.getAll(),
766
+ cwd: process.cwd(),
767
+ });
768
+ const systemTokens = estimateTokens(systemPrompt);
769
+ const messageTokens = history.reduce((sum, m) => sum + estimateTokens(getTextContent(m.content)), 0);
770
+ const toolTokens = defaultToolRegistry
771
+ .getAll()
772
+ .reduce((sum, t) => sum + estimateTokens(JSON.stringify(t)), 0);
773
+ const total = systemTokens + messageTokens + toolTokens;
774
+ // Use the context manager's actual budget (model-aware, not hardcoded)
775
+ const budget = contextManager.getConfig().maxContextTokens;
776
+ return {
777
+ systemPrompt: systemTokens,
778
+ nimbusInstructions: 0,
779
+ messages: messageTokens,
780
+ toolDefinitions: toolTokens,
781
+ total,
782
+ budget,
783
+ usagePercent: Math.round((total / budget) * 100),
784
+ };
785
+ };
786
+ /**
787
+ * Handle /undo command.
788
+ */
789
+ const onUndo = async () => {
790
+ return snapshotManager.undo();
791
+ };
792
+ /**
793
+ * Handle /redo command.
794
+ */
795
+ const onRedo = async () => {
796
+ return snapshotManager.redo();
797
+ };
798
+ /**
799
+ * Handle /models command — list all available models across providers.
800
+ */
801
+ const onModels = async () => {
802
+ return ctx.router.getAvailableModels();
803
+ };
804
+ /**
805
+ * Handle /clear command — reset the LLM conversation history.
806
+ */
807
+ const onClear = () => {
808
+ history = [];
809
+ };
810
+ /**
811
+ * Handle /model change — update the model used by the agent loop.
812
+ */
813
+ const onModelChange = (model) => {
814
+ currentModel = model;
815
+ // Update context manager's budget for the new model
816
+ contextManager.setModel(model);
817
+ };
818
+ /**
819
+ * Handle mode change (Tab or /mode) — update the mode used by the agent loop.
820
+ * Resets permission state to prevent privilege escalation.
821
+ */
822
+ const onModeChange = (newMode) => {
823
+ currentMode = newMode;
824
+ // Reset permission state when switching modes (security)
825
+ Object.assign(permissionState, createPermissionState());
826
+ };
827
+ // -------------------------------------------------------------------------
828
+ // A5: Per-turn cost log for /cost command
829
+ // -------------------------------------------------------------------------
830
+ const turnCostLog = [];
831
+ let previousTotalCost = 0;
832
+ let currentTurn = 0;
833
+ /**
834
+ * Handle /diff command — show unstaged git diff.
835
+ */
836
+ const onDiff = async () => {
837
+ const { spawnSync } = await import('node:child_process');
838
+ const stat = spawnSync('git', ['diff', '--stat'], { encoding: 'utf-8', cwd: process.cwd() });
839
+ const full = spawnSync('git', ['diff'], { encoding: 'utf-8', cwd: process.cwd() });
840
+ const statOut = stat.stdout?.trim() ?? '';
841
+ const fullOut = full.stdout?.trim() ?? '';
842
+ if (!statOut && !fullOut)
843
+ return 'No unstaged changes.';
844
+ return [statOut, fullOut].filter(Boolean).join('\n\n');
845
+ };
846
+ /**
847
+ * Handle /cost command — show per-turn cost breakdown.
848
+ */
849
+ const onCost = () => {
850
+ if (turnCostLog.length === 0)
851
+ return 'No turns yet.';
852
+ const rows = turnCostLog.map(t => ` Turn ${t.turn} ${t.tokens.toLocaleString()} tokens $${t.costUSD.toFixed(4)}`);
853
+ const total = turnCostLog.reduce((s, t) => s + t.costUSD, 0);
854
+ const totalTok = turnCostLog.reduce((s, t) => s + t.tokens, 0);
855
+ return [
856
+ 'Cost breakdown:',
857
+ ...rows,
858
+ ` ${'─'.repeat(40)}`,
859
+ ` Total ${totalTok.toLocaleString()} tokens $${total.toFixed(4)}`,
860
+ ].join('\n');
861
+ };
862
+ /**
863
+ * Handle /init command — regenerate NIMBUS.md from inside the TUI.
864
+ */
865
+ const onInit = async () => {
866
+ const { runInit } = await import('../../cli/init');
867
+ const result = await runInit({ cwd: process.cwd(), quiet: false });
868
+ if (result.nimbusmdPath && existsSync(result.nimbusmdPath)) {
869
+ nimbusInstructions = readFileSync(result.nimbusmdPath, 'utf-8');
870
+ return `NIMBUS.md generated at ${result.nimbusmdPath}. Context updated.`;
871
+ }
872
+ return 'Init complete (no NIMBUS.md generated).';
873
+ };
874
+ /**
875
+ * Handle /export [filename] — serialize conversation to a runbook markdown file. G16
876
+ */
877
+ const onExport = async (filename) => {
878
+ const { join } = await import('node:path');
879
+ const { writeFileSync } = await import('node:fs');
880
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
881
+ const targetFile = filename ?? join(process.cwd(), `nimbus-session-${timestamp}.md`);
882
+ const lines = [
883
+ `# Nimbus Session Export`,
884
+ `Session: ${sessionId ?? 'unknown'} | Mode: ${currentMode} | Date: ${new Date().toISOString()}`,
885
+ '',
886
+ '## Conversation',
887
+ '',
888
+ ];
889
+ for (const msg of history) {
890
+ const role = msg.role === 'user' ? '**User**' : '**Agent**';
891
+ const contentStr = Array.isArray(msg.content)
892
+ ? msg.content.map((b) => (typeof b === 'object' && b !== null && 'text' in b ? b.text : '')).join('')
893
+ : String(msg.content ?? '');
894
+ lines.push(`${role}: ${contentStr}`);
895
+ lines.push('');
896
+ }
897
+ writeFileSync(targetFile, lines.join('\n'), 'utf-8');
898
+ return targetFile;
899
+ };
900
+ /**
901
+ * Handle /remember <fact> — append fact to NIMBUS.md Agent Memory. G17
902
+ */
903
+ const onRemember = async (fact) => {
904
+ // Find the NIMBUS.md path in use
905
+ const nimbusMdPath = nimbusMdPaths.find(p => {
906
+ try {
907
+ return existsSync(p);
908
+ }
909
+ catch {
910
+ return false;
911
+ }
912
+ }) ?? nimbusMdPaths[0];
913
+ let content = '';
914
+ try {
915
+ if (existsSync(nimbusMdPath)) {
916
+ content = readFileSync(nimbusMdPath, 'utf-8');
917
+ }
918
+ }
919
+ catch { /* will create new */ }
920
+ const MEMORY_SECTION = '## Agent Memory';
921
+ if (content.includes(MEMORY_SECTION)) {
922
+ // Append to existing section
923
+ content = content.replace(new RegExp(`(${MEMORY_SECTION}[\\s\\S]*?)(?=\\n##|$)`), `$1\n- ${fact}`);
924
+ }
925
+ else {
926
+ content += `\n${MEMORY_SECTION}\n\n- ${fact}\n`;
927
+ }
928
+ const { writeFileSync, mkdirSync } = await import('node:fs');
929
+ const { dirname } = await import('node:path');
930
+ mkdirSync(dirname(nimbusMdPath), { recursive: true });
931
+ writeFileSync(nimbusMdPath, content, 'utf-8');
932
+ // Reload instructions
933
+ nimbusInstructions = content;
934
+ };
935
+ /**
936
+ * Handle /sessions command — list active sessions.
937
+ */
938
+ const onSessions = () => {
939
+ if (!sessionManager) {
940
+ return [];
941
+ }
942
+ try {
943
+ const sessions = sessionManager.list();
944
+ // L9: include token and cost summary
945
+ let totalTokens = 0;
946
+ let totalCost = 0;
947
+ const mapped = sessions.map(s => {
948
+ const tokens = s.tokenCount ?? 0;
949
+ const cost = s.costUSD ?? 0;
950
+ totalTokens += tokens;
951
+ totalCost += cost;
952
+ return {
953
+ id: s.id,
954
+ name: s.name ?? `session-${s.id.slice(0, 8)}`,
955
+ model: s.model ?? 'default',
956
+ mode: (s.mode ?? 'build'),
957
+ updatedAt: s.updatedAt ?? new Date().toISOString(),
958
+ tokenCount: tokens,
959
+ costUSD: cost,
960
+ };
961
+ });
962
+ // Append a total row as a synthetic session entry
963
+ if (mapped.length > 0) {
964
+ mapped.push({
965
+ id: '__total__',
966
+ name: `Total (${mapped.length} sessions)`,
967
+ model: '',
968
+ mode: '',
969
+ updatedAt: '',
970
+ tokenCount: totalTokens,
971
+ costUSD: totalCost,
972
+ });
973
+ }
974
+ return mapped;
975
+ }
976
+ catch {
977
+ return [];
978
+ }
979
+ };
980
+ /**
981
+ * Handle /new command — create a new session, reset history.
982
+ */
983
+ const onNewSession = (name) => {
984
+ if (!sessionManager) {
985
+ return null;
986
+ }
987
+ try {
988
+ const newSession = sessionManager.create({
989
+ name: name ?? `chat-${new Date().toISOString().slice(0, 16)}`,
990
+ mode: currentMode,
991
+ model: currentModel,
992
+ cwd: process.cwd(),
993
+ });
994
+ // Reset conversation history for the new session
995
+ history = [];
996
+ sessionId = newSession.id;
997
+ return {
998
+ id: newSession.id,
999
+ name: newSession.name ?? name ?? 'new session',
1000
+ model: currentModel ?? 'default',
1001
+ mode: currentMode,
1002
+ updatedAt: new Date().toISOString(),
1003
+ };
1004
+ }
1005
+ catch {
1006
+ return null;
1007
+ }
1008
+ };
1009
+ /**
1010
+ * Handle /switch command — switch to a different session.
1011
+ */
1012
+ const onSwitchSession = (targetId) => {
1013
+ if (!sessionManager) {
1014
+ return null;
1015
+ }
1016
+ try {
1017
+ // Find session by ID prefix match
1018
+ const sessions = sessionManager.list();
1019
+ const target = sessions.find(s => s.id === targetId || s.id.startsWith(targetId));
1020
+ if (!target) {
1021
+ return null;
1022
+ }
1023
+ // Save current conversation before switching
1024
+ if (sessionId) {
1025
+ try {
1026
+ sessionManager.saveConversation(sessionId, history);
1027
+ }
1028
+ catch {
1029
+ /* non-critical */
1030
+ }
1031
+ }
1032
+ // Load the target session's conversation
1033
+ sessionId = target.id;
1034
+ sessionManager.resume(target.id);
1035
+ try {
1036
+ const restored = sessionManager.loadConversation(target.id);
1037
+ history = restored;
1038
+ }
1039
+ catch {
1040
+ history = [];
1041
+ }
1042
+ return {
1043
+ id: target.id,
1044
+ name: target.name ?? `session-${target.id.slice(0, 8)}`,
1045
+ model: target.model ?? 'default',
1046
+ mode: target.mode ?? 'build',
1047
+ updatedAt: target.updatedAt ?? new Date().toISOString(),
1048
+ };
1049
+ }
1050
+ catch {
1051
+ return null;
1052
+ }
1053
+ };
1054
+ // Convert restored LLM history into UIMessages for the TUI
1055
+ const restoredMessages = history
1056
+ .filter(m => m.role === 'user' || m.role === 'assistant')
1057
+ .map(m => ({
1058
+ id: crypto.randomUUID(),
1059
+ role: m.role,
1060
+ content: getTextContent(m.content),
1061
+ timestamp: new Date(),
1062
+ }));
1063
+ // Show a welcome message on fresh sessions (no prior history)
1064
+ const isNewSession = restoredMessages.length === 0;
1065
+ // L4: Check for prior sessions to show resume hint
1066
+ let priorSessionCount = 0;
1067
+ try {
1068
+ if (sessionManager) {
1069
+ const allSessions = sessionManager.list();
1070
+ priorSessionCount = allSessions.filter(s => s.id !== sessionId).length;
1071
+ }
1072
+ }
1073
+ catch { /* non-critical */ }
1074
+ const welcomeMessage = isNewSession
1075
+ ? (() => {
1076
+ // G10: DevOps-context-aware welcome message
1077
+ const infraLines = [];
1078
+ if (currentInfraContext?.kubectlContext) {
1079
+ infraLines.push(` Kubernetes: ${currentInfraContext.kubectlContext}`);
1080
+ }
1081
+ if (currentInfraContext?.terraformWorkspace) {
1082
+ infraLines.push(` Terraform: workspace=${currentInfraContext.terraformWorkspace}`);
1083
+ }
1084
+ if (currentInfraContext?.awsAccount) {
1085
+ infraLines.push(` AWS: ${currentInfraContext.awsAccount}${currentInfraContext.awsRegion ? ` / ${currentInfraContext.awsRegion}` : ''}`);
1086
+ }
1087
+ if (currentInfraContext?.gcpProject) {
1088
+ infraLines.push(` GCP: ${currentInfraContext.gcpProject}`);
1089
+ }
1090
+ // GAP-17: context-aware suggestions based on detected infrastructure
1091
+ const suggestions = [];
1092
+ if (currentInfraContext?.terraformWorkspace)
1093
+ suggestions.push(`"check for drift in workspace ${currentInfraContext.terraformWorkspace}"`);
1094
+ if (currentInfraContext?.kubectlContext)
1095
+ suggestions.push(`"show all pods in ${currentInfraContext.kubectlContext}"`);
1096
+ if (currentInfraContext?.awsAccount)
1097
+ suggestions.push(`"show AWS costs for this month"`);
1098
+ if ((currentInfraContext?.helmReleases?.length ?? 0) > 0)
1099
+ suggestions.push(`"show helm release history for ${currentInfraContext.helmReleases[0]}"`);
1100
+ // H5: Build one-line infra hint for cold start
1101
+ const infraHintParts = [];
1102
+ if (currentInfraContext?.terraformWorkspace)
1103
+ infraHintParts.push(`tf:${currentInfraContext.terraformWorkspace}`);
1104
+ if (currentInfraContext?.kubectlContext)
1105
+ infraHintParts.push(`k8s:${currentInfraContext.kubectlContext}`);
1106
+ if (currentInfraContext?.awsAccount)
1107
+ infraHintParts.push(`aws:${currentInfraContext.awsAccount}`);
1108
+ if (currentInfraContext?.gcpProject)
1109
+ infraHintParts.push(`gcp:${currentInfraContext.gcpProject}`);
1110
+ if ((currentInfraContext?.helmReleases?.length ?? 0) > 0)
1111
+ infraHintParts.push(`${currentInfraContext.helmReleases.length} helm release${currentInfraContext.helmReleases.length > 1 ? 's' : ''}`);
1112
+ const infraHintLine = infraHintParts.length > 0 ? `Infra detected: ${infraHintParts.join(' | ')}` : '';
1113
+ // G24: DevOps-specific quick-start examples
1114
+ // M3: When no NIMBUS.md, show concrete DevOps prompt examples to reduce blank-prompt friction
1115
+ const noNimbusHints = !nimbusInstructions ? [
1116
+ '',
1117
+ 'Try asking:',
1118
+ ' "list my kubernetes pods in the staging namespace"',
1119
+ ' "run terraform plan in ./infrastructure"',
1120
+ ' "show me the helm releases and their status"',
1121
+ ' "check for infrastructure drift"',
1122
+ ] : [];
1123
+ const content = [
1124
+ 'Welcome to Nimbus — Your AI DevOps Operator.',
1125
+ ...(infraHintLine ? ['', infraHintLine] : []),
1126
+ '',
1127
+ ...(infraLines.length > 0 ? ['Detected infrastructure:', ...infraLines, ''] : []),
1128
+ ...(suggestions.length > 0 ? ['', 'Suggested:', ...suggestions.map(s => ` • ${s}`)] : []),
1129
+ ...noNimbusHints,
1130
+ '',
1131
+ 'Mode: PLAN (read-only). Tab → build → deploy to escalate.',
1132
+ '',
1133
+ 'Quick-start examples:',
1134
+ ' "Show me all failing pods across all namespaces"',
1135
+ ' "What terraform changes are pending in the staging workspace?"',
1136
+ ' "Check for infrastructure drift between actual and desired state"',
1137
+ ' "Summarize last 24 hours of production incidents in PagerDuty"',
1138
+ '',
1139
+ '/k8s-ctx — switch cluster /tf-ws — switch workspace',
1140
+ '/help — all commands Tab — cycle modes',
1141
+ '',
1142
+ nimbusInstructions
1143
+ ? 'NIMBUS.md loaded — project context active.'
1144
+ : 'Tip: run `nimbus init` to generate a NIMBUS.md with your infra context.',
1145
+ // L4: Session resume hint
1146
+ ...(priorSessionCount > 0
1147
+ ? ['', 'Previous session available — type /sessions to resume or /new to start fresh.']
1148
+ : []),
1149
+ ].join('\n');
1150
+ return {
1151
+ id: crypto.randomUUID(),
1152
+ role: 'system',
1153
+ content,
1154
+ timestamp: new Date(),
1155
+ };
1156
+ })()
1157
+ : null;
1158
+ // Gap 19: append any startup warnings as a system message
1159
+ const startupWarningMessages = _startupWarnings.length > 0
1160
+ ? [{
1161
+ id: crypto.randomUUID(),
1162
+ role: 'system',
1163
+ content: `Startup warnings:\n${_startupWarnings.map(w => ` ⚠ ${w}`).join('\n')}`,
1164
+ timestamp: new Date(),
1165
+ }]
1166
+ : [];
1167
+ // G4: Proactive NIMBUS.md banner when auto-init failed to create one
1168
+ const nimbusMdBannerMessage = showNimbusMdBanner ? {
1169
+ id: crypto.randomUUID(),
1170
+ role: 'system',
1171
+ content: [
1172
+ '**No NIMBUS.md found in this directory.**',
1173
+ '',
1174
+ 'Type `/init` to auto-generate project context — I\'ll detect your Terraform workspaces,',
1175
+ 'Kubernetes clusters, AWS accounts, and more.',
1176
+ '',
1177
+ 'Or ask me anything directly. I work best with project context loaded.',
1178
+ ].join('\n'),
1179
+ timestamp: new Date(),
1180
+ } : null;
1181
+ const initialMessages = [
1182
+ ...(welcomeMessage ? [welcomeMessage] : []),
1183
+ ...(nimbusMdBannerMessage ? [nimbusMdBannerMessage] : []),
1184
+ ...(resumeContextMessage ? [resumeContextMessage] : []),
1185
+ ...startupWarningMessages,
1186
+ ...restoredMessages,
1187
+ ];
1188
+ // Build props for the App component
1189
+ const appProps = {
1190
+ initialSession: {
1191
+ model: options.model ?? 'default',
1192
+ mode: currentMode,
1193
+ kubectlContext: currentInfraContext?.kubectlContext,
1194
+ terraformWorkspace: currentInfraContext?.terraformWorkspace,
1195
+ },
1196
+ initialMessages: initialMessages.length > 0 ? initialMessages : undefined,
1197
+ onMessage,
1198
+ onAbort,
1199
+ onCompact,
1200
+ onContext,
1201
+ onUndo,
1202
+ onRedo,
1203
+ onModels,
1204
+ onClear,
1205
+ onModelChange,
1206
+ onModeChange,
1207
+ onDiff,
1208
+ onCost,
1209
+ onInit,
1210
+ onExport,
1211
+ onRemember,
1212
+ onSessions,
1213
+ onNewSession,
1214
+ onSwitchSession,
1215
+ onFetchCompletions: async (prefix) => {
1216
+ // H3: Fetch dynamic completions for slash command arguments (cached 30s in InputBox)
1217
+ try {
1218
+ const { execFile } = await import('node:child_process');
1219
+ const { promisify } = await import('node:util');
1220
+ const execFileAsync = promisify(execFile);
1221
+ if (prefix.startsWith('/k8s-ctx ')) {
1222
+ const { stdout } = await execFileAsync('kubectl', ['config', 'get-contexts', '-o', 'name'], { timeout: 5000 });
1223
+ return stdout.trim().split('\n').filter(Boolean);
1224
+ }
1225
+ if (prefix.startsWith('/tf-ws ')) {
1226
+ const { stdout } = await execFileAsync('terraform', ['workspace', 'list'], { timeout: 10000, cwd: process.cwd() });
1227
+ return stdout.trim().split('\n').map(l => l.replace(/^\*?\s+/, '')).filter(Boolean);
1228
+ }
1229
+ if (prefix.startsWith('/model ')) {
1230
+ const modelsMap = await ctx.router.getAvailableModels();
1231
+ return Object.values(modelsMap).flat();
1232
+ }
1233
+ if (prefix.startsWith('/profile ')) {
1234
+ const { listProfiles } = await import('../../config/profiles');
1235
+ return listProfiles();
1236
+ }
1237
+ }
1238
+ catch { /* non-critical */ }
1239
+ return [];
1240
+ },
1241
+ onReady: imperativeApi => {
1242
+ api = imperativeApi;
1243
+ // GAP-2: Fire background LLM connectivity check after API is ready
1244
+ api.setLLMHealth('checking');
1245
+ (async () => {
1246
+ try {
1247
+ const providers = await ctx.router.getAvailableProviders();
1248
+ if (providers.length > 0) {
1249
+ api.setLLMHealth('ok');
1250
+ }
1251
+ else {
1252
+ api.setLLMHealth('error');
1253
+ }
1254
+ }
1255
+ catch {
1256
+ api.setLLMHealth('error');
1257
+ }
1258
+ })();
1259
+ },
1260
+ };
1261
+ // Render the Ink application wrapped in an error boundary
1262
+ const inkInstance = render(React.createElement(AppErrorBoundary, null, React.createElement(App, { ...appProps, columns: process.stdout.columns ?? 80 })));
1263
+ const { waitUntilExit } = inkInstance;
1264
+ // C1: Re-render on terminal resize so Ink layout reflows correctly
1265
+ const handleResize = () => {
1266
+ try {
1267
+ inkInstance.rerender(React.createElement(AppErrorBoundary, null, React.createElement(App, { ...appProps, columns: process.stdout.columns ?? 80 })));
1268
+ }
1269
+ catch { /* non-critical */ }
1270
+ };
1271
+ process.stdout.on('resize', handleResize);
1272
+ process.on('SIGWINCH', handleResize);
1273
+ // Gap 16: Periodic cloud auth status check every 15 minutes
1274
+ const authCheckInterval = setInterval(async () => {
1275
+ try {
1276
+ const { execFile } = await import('node:child_process');
1277
+ const { promisify } = await import('node:util');
1278
+ const execFileAsync = promisify(execFile);
1279
+ const expired = [];
1280
+ // Check AWS
1281
+ try {
1282
+ await execFileAsync('aws', ['sts', 'get-caller-identity'], { timeout: 5000 });
1283
+ }
1284
+ catch {
1285
+ expired.push('AWS');
1286
+ }
1287
+ if (expired.length > 0) {
1288
+ addMessage({
1289
+ id: crypto.randomUUID(),
1290
+ role: 'system',
1291
+ content: `Cloud credentials may have expired: ${expired.join(', ')}. Run /auth-refresh to renew.`,
1292
+ timestamp: new Date(),
1293
+ });
1294
+ }
1295
+ }
1296
+ catch { /* non-critical */ }
1297
+ }, 15 * 60 * 1000);
1298
+ // When the TUI exits, clean up watcher, LSP servers, and mark session as completed
1299
+ process.on('exit', () => {
1300
+ clearInterval(authCheckInterval);
1301
+ watcher.stop();
1302
+ lspManager.stopAll();
1303
+ if (sessionManager && sessionId) {
1304
+ try {
1305
+ sessionManager.complete(sessionId);
1306
+ }
1307
+ catch {
1308
+ /* ignore */
1309
+ }
1310
+ }
1311
+ // H1: Persist final infra context on exit so next session starts with it
1312
+ if (currentInfraContext) {
1313
+ try {
1314
+ writeFileSync(infraStatePath, JSON.stringify(currentInfraContext, null, 2), 'utf-8');
1315
+ }
1316
+ catch { /* non-critical */ }
1317
+ }
1318
+ });
1319
+ // Keep the process alive until the user exits (Ctrl+C twice, or exit())
1320
+ await waitUntilExit();
1321
+ // A7: Session saved hint on exit
1322
+ if (sessionId && process.stderr.isTTY) {
1323
+ process.stderr.write('\n\x1b[2mSession saved. Resume with: nimbus chat --continue\x1b[0m\n');
1324
+ }
1325
+ }