@build-astron-co/nimbus 0.4.1 → 0.4.3

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