@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,958 @@
1
+ /**
2
+ * GAP-11, GAP-18, GAP-20 Tests
3
+ *
4
+ * GAP-11: Terraform plan → FileDiffBatch wiring
5
+ * - parseTerraformPlanOutput with various plan outputs
6
+ * - buildFileDiffBatchFromPlan output shape
7
+ * - requestFileDiff callback called after terraform plan
8
+ *
9
+ * GAP-18: IaC validation after writing .tf files
10
+ * - .tf file detection from tool name and path
11
+ * - terraform validate error injection into tool result
12
+ *
13
+ * GAP-20: Per-tool timeout from NIMBUS.md
14
+ * - parseToolTimeouts function parsing
15
+ * - timeout propagation to ToolExecuteContext
16
+ * - NIMBUS.md ## Tool Timeouts section parsing
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import {
21
+ parseTerraformPlanOutput,
22
+ buildFileDiffBatchFromPlan,
23
+ type ResourceChange,
24
+ type DeployPreview,
25
+ } from '../agent/deploy-preview';
26
+ import { ToolRegistry } from '../tools/schemas/types';
27
+ import type { ToolDefinition, ToolExecuteContext } from '../tools/schemas/types';
28
+ import type { FileDiffDecision } from '../agent/loop';
29
+ import { z } from 'zod';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // GAP-11 Tests: parseTerraformPlanOutput
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('GAP-11 — parseTerraformPlanOutput', () => {
36
+ it('parses a create resource line', () => {
37
+ const output = ` # aws_instance.web will be created`;
38
+ const changes = parseTerraformPlanOutput(output);
39
+ expect(changes).toHaveLength(1);
40
+ expect(changes[0].resource).toBe('aws_instance.web');
41
+ expect(changes[0].action).toBe('create');
42
+ });
43
+
44
+ it('parses an update resource line', () => {
45
+ const output = ` # aws_s3_bucket.data will be updated in-place`;
46
+ const changes = parseTerraformPlanOutput(output);
47
+ expect(changes).toHaveLength(1);
48
+ expect(changes[0].resource).toBe('aws_s3_bucket.data');
49
+ expect(changes[0].action).toBe('update');
50
+ });
51
+
52
+ it('parses a destroy resource line', () => {
53
+ const output = ` # aws_security_group.old will be destroyed`;
54
+ const changes = parseTerraformPlanOutput(output);
55
+ expect(changes).toHaveLength(1);
56
+ expect(changes[0].resource).toBe('aws_security_group.old');
57
+ expect(changes[0].action).toBe('destroy');
58
+ });
59
+
60
+ it('parses a replace resource line', () => {
61
+ const output = ` # aws_instance.app must be replaced`;
62
+ const changes = parseTerraformPlanOutput(output);
63
+ expect(changes).toHaveLength(1);
64
+ expect(changes[0].resource).toBe('aws_instance.app');
65
+ expect(changes[0].action).toBe('replace');
66
+ });
67
+
68
+ it('parses multiple resources in a plan output', () => {
69
+ const output = [
70
+ ' # aws_vpc.main will be created',
71
+ ' # aws_subnet.public will be created',
72
+ ' # aws_instance.old will be destroyed',
73
+ ' # aws_instance.web will be updated in-place',
74
+ ' # aws_rds_cluster.db must be replaced',
75
+ ].join('\n');
76
+ const changes = parseTerraformPlanOutput(output);
77
+ expect(changes).toHaveLength(5);
78
+ expect(changes.filter(c => c.action === 'create')).toHaveLength(2);
79
+ expect(changes.filter(c => c.action === 'destroy')).toHaveLength(1);
80
+ expect(changes.filter(c => c.action === 'update')).toHaveLength(1);
81
+ expect(changes.filter(c => c.action === 'replace')).toHaveLength(1);
82
+ });
83
+
84
+ it('falls back to summary line when no resource lines present', () => {
85
+ const output = `Plan: 2 to add, 1 to change, 1 to destroy.`;
86
+ const changes = parseTerraformPlanOutput(output);
87
+ expect(changes).toHaveLength(4);
88
+ expect(changes.filter(c => c.action === 'create')).toHaveLength(2);
89
+ expect(changes.filter(c => c.action === 'update')).toHaveLength(1);
90
+ expect(changes.filter(c => c.action === 'destroy')).toHaveLength(1);
91
+ });
92
+
93
+ it('returns empty array for plan with no changes', () => {
94
+ const output = `No changes. Your infrastructure matches the configuration.`;
95
+ const changes = parseTerraformPlanOutput(output);
96
+ expect(changes).toHaveLength(0);
97
+ });
98
+
99
+ it('ignores the summary line if individual resource lines were already parsed', () => {
100
+ const output = [
101
+ ' # aws_instance.web will be created',
102
+ 'Plan: 3 to add, 0 to change, 0 to destroy.',
103
+ ].join('\n');
104
+ const changes = parseTerraformPlanOutput(output);
105
+ // Should only have 1 entry from the resource line, not 3+1
106
+ expect(changes).toHaveLength(1);
107
+ expect(changes[0].resource).toBe('aws_instance.web');
108
+ });
109
+
110
+ it('handles module-prefixed resource names', () => {
111
+ const output = ` # module.vpc.aws_vpc.main will be created`;
112
+ const changes = parseTerraformPlanOutput(output);
113
+ expect(changes).toHaveLength(1);
114
+ expect(changes[0].resource).toBe('module.vpc.aws_vpc.main');
115
+ });
116
+
117
+ it('parses plan with only destroy actions', () => {
118
+ const output = [
119
+ ' # aws_route53_record.old will be destroyed',
120
+ ' # aws_iam_role.legacy will be destroyed',
121
+ ].join('\n');
122
+ const changes = parseTerraformPlanOutput(output);
123
+ expect(changes).toHaveLength(2);
124
+ expect(changes.every(c => c.action === 'destroy')).toBe(true);
125
+ });
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // GAP-11 Tests: buildFileDiffBatchFromPlan
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe('GAP-11 — buildFileDiffBatchFromPlan', () => {
133
+ function makePreview(changes: ResourceChange[]): DeployPreview {
134
+ return {
135
+ tool: 'terraform',
136
+ action: 'plan',
137
+ workdir: '/tmp/infra',
138
+ changes,
139
+ summary: {
140
+ toCreate: changes.filter(c => c.action === 'create').length,
141
+ toUpdate: changes.filter(c => c.action === 'update').length,
142
+ toDestroy: changes.filter(c => c.action === 'destroy').length,
143
+ toReplace: changes.filter(c => c.action === 'replace').length,
144
+ unchanged: 0,
145
+ },
146
+ rawOutput: '',
147
+ success: true,
148
+ };
149
+ }
150
+
151
+ it('returns one entry per resource change', () => {
152
+ const preview = makePreview([
153
+ { resource: 'aws_instance.web', action: 'create' },
154
+ { resource: 'aws_s3_bucket.data', action: 'update' },
155
+ ]);
156
+ const batch = buildFileDiffBatchFromPlan(preview);
157
+ expect(batch).toHaveLength(2);
158
+ });
159
+
160
+ it('each entry has filePath, diff, and toolName', () => {
161
+ const preview = makePreview([{ resource: 'aws_vpc.main', action: 'create' }]);
162
+ const batch = buildFileDiffBatchFromPlan(preview);
163
+ expect(batch[0]).toHaveProperty('filePath');
164
+ expect(batch[0]).toHaveProperty('diff');
165
+ expect(batch[0]).toHaveProperty('toolName');
166
+ });
167
+
168
+ it('filePath equals the resource name', () => {
169
+ const preview = makePreview([{ resource: 'aws_instance.app', action: 'destroy' }]);
170
+ const batch = buildFileDiffBatchFromPlan(preview);
171
+ expect(batch[0].filePath).toBe('aws_instance.app');
172
+ });
173
+
174
+ it('toolName is "terraform" for all entries', () => {
175
+ const preview = makePreview([
176
+ { resource: 'aws_vpc.main', action: 'create' },
177
+ { resource: 'aws_subnet.pub', action: 'update' },
178
+ ]);
179
+ const batch = buildFileDiffBatchFromPlan(preview);
180
+ expect(batch.every(b => b.toolName === 'terraform')).toBe(true);
181
+ });
182
+
183
+ it('diff contains a unified diff header', () => {
184
+ const preview = makePreview([{ resource: 'aws_instance.web', action: 'create' }]);
185
+ const batch = buildFileDiffBatchFromPlan(preview);
186
+ expect(batch[0].diff).toContain('--- a/aws_instance.web');
187
+ expect(batch[0].diff).toContain('+++ b/aws_instance.web');
188
+ });
189
+
190
+ it('diff uses "+" symbol for create action', () => {
191
+ const preview = makePreview([{ resource: 'aws_lambda.fn', action: 'create' }]);
192
+ const batch = buildFileDiffBatchFromPlan(preview);
193
+ expect(batch[0].diff).toContain('+ aws_lambda.fn');
194
+ });
195
+
196
+ it('diff uses "-" symbol for destroy action', () => {
197
+ const preview = makePreview([{ resource: 'aws_iam_role.old', action: 'destroy' }]);
198
+ const batch = buildFileDiffBatchFromPlan(preview);
199
+ expect(batch[0].diff).toContain('- aws_iam_role.old');
200
+ });
201
+
202
+ it('diff uses "~" symbol for update action', () => {
203
+ const preview = makePreview([{ resource: 'aws_rds_instance.db', action: 'update' }]);
204
+ const batch = buildFileDiffBatchFromPlan(preview);
205
+ expect(batch[0].diff).toContain('~ aws_rds_instance.db');
206
+ });
207
+
208
+ it('diff includes details when present', () => {
209
+ const preview = makePreview([
210
+ { resource: 'aws_instance.web', action: 'create', details: 'ami changed' },
211
+ ]);
212
+ const batch = buildFileDiffBatchFromPlan(preview);
213
+ expect(batch[0].diff).toContain('ami changed');
214
+ });
215
+
216
+ it('returns empty array for plan with no changes', () => {
217
+ const preview = makePreview([]);
218
+ const batch = buildFileDiffBatchFromPlan(preview);
219
+ expect(batch).toHaveLength(0);
220
+ });
221
+
222
+ it('handles replace action correctly', () => {
223
+ const preview = makePreview([{ resource: 'aws_instance.app', action: 'replace' }]);
224
+ const batch = buildFileDiffBatchFromPlan(preview);
225
+ expect(batch[0].diff).toContain('+/-');
226
+ });
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // GAP-11 Tests: requestFileDiff callback integration
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe('GAP-11 — requestFileDiff callback with terraform plan', () => {
234
+ it('parseTerraformPlanOutput + buildFileDiffBatchFromPlan together produce requestable diffs', () => {
235
+ const planOutput = [
236
+ ' # aws_instance.web will be created',
237
+ ' # aws_security_group.main will be updated in-place',
238
+ ].join('\n');
239
+
240
+ const changes = parseTerraformPlanOutput(planOutput);
241
+ expect(changes).toHaveLength(2);
242
+
243
+ // Build a minimal preview to feed buildFileDiffBatchFromPlan
244
+ const preview: DeployPreview = {
245
+ tool: 'terraform',
246
+ action: 'plan',
247
+ workdir: '/tmp',
248
+ changes,
249
+ summary: { toCreate: 1, toUpdate: 1, toDestroy: 0, toReplace: 0, unchanged: 0 },
250
+ rawOutput: planOutput,
251
+ success: true,
252
+ };
253
+
254
+ const batch = buildFileDiffBatchFromPlan(preview);
255
+ expect(batch).toHaveLength(2);
256
+
257
+ // Simulate the requestFileDiff callback being called for each
258
+ const calls: Array<[string, string, string]> = [];
259
+ const fakeRequestFileDiff = async (path: string, toolName: string, diff: string): Promise<FileDiffDecision> => {
260
+ calls.push([path, toolName, diff]);
261
+ return 'apply';
262
+ };
263
+
264
+ // Run through the batch as loop.ts would
265
+ const runBatch = async () => {
266
+ for (const file of batch) {
267
+ const decision = await fakeRequestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
268
+ if (decision === 'reject-all') break;
269
+ }
270
+ };
271
+
272
+ return runBatch().then(() => {
273
+ expect(calls).toHaveLength(2);
274
+ expect(calls[0][0]).toBe('aws_instance.web');
275
+ expect(calls[0][1]).toBe('terraform');
276
+ expect(calls[1][0]).toBe('aws_security_group.main');
277
+ });
278
+ });
279
+
280
+ it('stops iteration on reject-all decision', async () => {
281
+ const changes: ResourceChange[] = [
282
+ { resource: 'aws_vpc.main', action: 'create' },
283
+ { resource: 'aws_subnet.pub', action: 'create' },
284
+ { resource: 'aws_sg.main', action: 'create' },
285
+ ];
286
+ const preview: DeployPreview = {
287
+ tool: 'terraform', action: 'plan', workdir: '/tmp',
288
+ changes, summary: { toCreate: 3, toUpdate: 0, toDestroy: 0, toReplace: 0, unchanged: 0 },
289
+ rawOutput: '', success: true,
290
+ };
291
+
292
+ const batch = buildFileDiffBatchFromPlan(preview);
293
+ const callCount = { n: 0 };
294
+ const fakeRequestFileDiff = async (_path: string, _toolName: string, _diff: string): Promise<FileDiffDecision> => {
295
+ callCount.n++;
296
+ return callCount.n === 1 ? 'reject-all' : 'apply';
297
+ };
298
+
299
+ for (const file of batch) {
300
+ const decision = await fakeRequestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
301
+ if (decision === 'reject-all') break;
302
+ }
303
+
304
+ // Only 1 call should have been made before reject-all stopped iteration
305
+ expect(callCount.n).toBe(1);
306
+ });
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // GAP-18 Tests: .tf file detection
311
+ // ---------------------------------------------------------------------------
312
+
313
+ describe('GAP-18 — .tf file detection logic', () => {
314
+ const FILE_WRITING_TOOLS = ['write_file', 'edit_file', 'multi_edit'];
315
+
316
+ function shouldValidateTf(toolName: string, filePath: string): boolean {
317
+ return FILE_WRITING_TOOLS.includes(toolName) && filePath.endsWith('.tf');
318
+ }
319
+
320
+ it('detects .tf extension for write_file with path', () => {
321
+ expect(shouldValidateTf('write_file', 'main.tf')).toBe(true);
322
+ expect(shouldValidateTf('write_file', '/infra/main.tf')).toBe(true);
323
+ });
324
+
325
+ it('detects .tf extension for edit_file', () => {
326
+ expect(shouldValidateTf('edit_file', 'variables.tf')).toBe(true);
327
+ });
328
+
329
+ it('detects .tf extension for multi_edit', () => {
330
+ expect(shouldValidateTf('multi_edit', 'outputs.tf')).toBe(true);
331
+ });
332
+
333
+ it('does NOT trigger for non-.tf files', () => {
334
+ expect(shouldValidateTf('write_file', 'main.py')).toBe(false);
335
+ expect(shouldValidateTf('write_file', 'values.yaml')).toBe(false);
336
+ expect(shouldValidateTf('write_file', 'Dockerfile')).toBe(false);
337
+ expect(shouldValidateTf('write_file', 'main.tfvars')).toBe(false);
338
+ });
339
+
340
+ it('does NOT trigger for non-file tools even with .tf in name', () => {
341
+ expect(shouldValidateTf('bash', 'something.tf')).toBe(false);
342
+ expect(shouldValidateTf('terraform', 'main.tf')).toBe(false);
343
+ expect(shouldValidateTf('read_file', 'main.tf')).toBe(false);
344
+ });
345
+
346
+ it('handles empty path without error', () => {
347
+ expect(shouldValidateTf('write_file', '')).toBe(false);
348
+ });
349
+
350
+ it('recognizes nested .tf paths', () => {
351
+ expect(shouldValidateTf('write_file', '/home/user/project/modules/vpc/main.tf')).toBe(true);
352
+ });
353
+
354
+ it('does NOT match .tf.bak or other .tf-prefixed extensions', () => {
355
+ expect(shouldValidateTf('write_file', 'main.tf.bak')).toBe(false);
356
+ });
357
+ });
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // GAP-18 Tests: terraform validate error injection
361
+ // ---------------------------------------------------------------------------
362
+
363
+ describe('GAP-18 — terraform validate error injection', () => {
364
+ it('produces correct error string from diagnostics', () => {
365
+ const diagnostics = [
366
+ { severity: 'error', summary: 'Missing required argument', detail: '"name" is required' },
367
+ { severity: 'error', summary: 'Invalid value', detail: '"region" must be a string' },
368
+ ];
369
+ const errors = diagnostics
370
+ .filter(d => d.severity === 'error')
371
+ .map(d => ` ${d.summary}: ${d.detail}`)
372
+ .join('\n');
373
+ const suffix = `\n\nTerraform validation errors (please fix):\n${errors}`;
374
+ expect(suffix).toContain('Missing required argument');
375
+ expect(suffix).toContain('"name" is required');
376
+ expect(suffix).toContain('Invalid value');
377
+ expect(suffix).toContain('"region" must be a string');
378
+ });
379
+
380
+ it('filters out warning-level diagnostics', () => {
381
+ const diagnostics = [
382
+ { severity: 'error', summary: 'Missing required argument', detail: '"name" is required' },
383
+ { severity: 'warning', summary: 'Deprecated', detail: 'Use newer syntax' },
384
+ ];
385
+ const errors = diagnostics
386
+ .filter(d => d.severity === 'error')
387
+ .map(d => ` ${d.summary}: ${d.detail}`)
388
+ .join('\n');
389
+ expect(errors).toContain('Missing required argument');
390
+ expect(errors).not.toContain('Deprecated');
391
+ });
392
+
393
+ it('produces no suffix when valid is true', () => {
394
+ const parsed = { valid: true, diagnostics: [] };
395
+ let toolContent = 'Success';
396
+ if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
397
+ toolContent += '\n\nTerraform validation errors (please fix):';
398
+ }
399
+ expect(toolContent).toBe('Success');
400
+ });
401
+
402
+ it('produces suffix when valid is false and errors exist', () => {
403
+ const parsed = {
404
+ valid: false,
405
+ diagnostics: [{ severity: 'error', summary: 'Error', detail: 'Something wrong' }],
406
+ };
407
+ let toolContent = 'File written';
408
+ if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
409
+ const errors = parsed.diagnostics
410
+ .filter(d => d.severity === 'error')
411
+ .map(d => ` ${d.summary}: ${d.detail}`)
412
+ .join('\n');
413
+ toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
414
+ }
415
+ expect(toolContent).toContain('Terraform validation errors (please fix)');
416
+ expect(toolContent).toContain('File written');
417
+ expect(toolContent).toContain('Something wrong');
418
+ });
419
+
420
+ it('handles diagnostics with empty detail gracefully', () => {
421
+ const diagnostics = [
422
+ { severity: 'error', summary: 'Syntax error', detail: '' },
423
+ ];
424
+ const errors = diagnostics
425
+ .filter(d => d.severity === 'error')
426
+ .map(d => ` ${d.summary}: ${d.detail}`)
427
+ .join('\n');
428
+ expect(errors).toContain('Syntax error');
429
+ expect(errors).toContain(':');
430
+ });
431
+
432
+ it('handles empty diagnostics array without suffix', () => {
433
+ const parsed = { valid: false, diagnostics: [] as Array<{severity: string; summary: string; detail: string}> };
434
+ let toolContent = 'File written';
435
+ if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
436
+ const errors = parsed.diagnostics
437
+ .filter(d => d.severity === 'error')
438
+ .map(d => ` ${d.summary}: ${d.detail}`)
439
+ .join('\n');
440
+ toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
441
+ }
442
+ // No errors even though valid is false (empty diagnostics)
443
+ expect(toolContent).toBe('File written');
444
+ });
445
+ });
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // GAP-20 Tests: parseToolTimeouts function
449
+ // ---------------------------------------------------------------------------
450
+
451
+ // Inline reproduction of parseToolTimeouts from ink/index.ts so we can unit-test it
452
+ function parseToolTimeouts(nimbusMd: string): Record<string, number> {
453
+ const result: Record<string, number> = {};
454
+ const match = nimbusMd.match(/##\s+Tool Timeouts\s*\n([\s\S]*?)(?=##|$)/);
455
+ if (!match) return result;
456
+ for (const line of match[1].split('\n')) {
457
+ const m = line.match(/^\s*([a-z_]+)\s*:\s*(\d+)\s*$/);
458
+ if (m) result[m[1]] = parseInt(m[2], 10);
459
+ }
460
+ return result;
461
+ }
462
+
463
+ describe('GAP-20 — parseToolTimeouts', () => {
464
+ it('returns empty object when no Tool Timeouts section', () => {
465
+ const nimbusMd = `## Project\nThis is a test project.\n\n## Instructions\nDo stuff.`;
466
+ const result = parseToolTimeouts(nimbusMd);
467
+ expect(result).toEqual({});
468
+ });
469
+
470
+ it('parses a single tool timeout', () => {
471
+ const nimbusMd = `## Tool Timeouts\nterraform: 300000\n`;
472
+ const result = parseToolTimeouts(nimbusMd);
473
+ expect(result).toEqual({ terraform: 300000 });
474
+ });
475
+
476
+ it('parses multiple tool timeouts', () => {
477
+ const nimbusMd = `## Tool Timeouts\nterraform: 600000\nkubectl: 120000\nhelm: 300000\n`;
478
+ const result = parseToolTimeouts(nimbusMd);
479
+ expect(result).toEqual({ terraform: 600000, kubectl: 120000, helm: 300000 });
480
+ });
481
+
482
+ it('parses tool timeouts with leading whitespace', () => {
483
+ const nimbusMd = `## Tool Timeouts\n terraform: 300000\n kubectl: 60000\n`;
484
+ const result = parseToolTimeouts(nimbusMd);
485
+ expect(result).toEqual({ terraform: 300000, kubectl: 60000 });
486
+ });
487
+
488
+ it('stops at the next ## section', () => {
489
+ const nimbusMd = `## Tool Timeouts\nterraform: 300000\n\n## Other Section\nkubectl: 999999\n`;
490
+ const result = parseToolTimeouts(nimbusMd);
491
+ // Only terraform should be parsed; kubectl appears after the next ##
492
+ expect(result).toHaveProperty('terraform', 300000);
493
+ expect(result).not.toHaveProperty('kubectl');
494
+ });
495
+
496
+ it('ignores non-matching lines in the section', () => {
497
+ const nimbusMd = `## Tool Timeouts\n# comment line\nterraform: 300000\nsome_text without colon\n`;
498
+ const result = parseToolTimeouts(nimbusMd);
499
+ expect(result).toEqual({ terraform: 300000 });
500
+ });
501
+
502
+ it('ignores lines with non-numeric values', () => {
503
+ const nimbusMd = `## Tool Timeouts\nterraform: fast\nkubectl: 120000\n`;
504
+ const result = parseToolTimeouts(nimbusMd);
505
+ // Only kubectl has a numeric value
506
+ expect(result).toEqual({ kubectl: 120000 });
507
+ });
508
+
509
+ it('ignores tool names with uppercase letters', () => {
510
+ const nimbusMd = `## Tool Timeouts\nTerraform: 300000\nkubectl: 120000\n`;
511
+ const result = parseToolTimeouts(nimbusMd);
512
+ // Terraform (capital T) doesn't match [a-z_]+ pattern
513
+ expect(result).toEqual({ kubectl: 120000 });
514
+ });
515
+
516
+ it('parses tool names with underscores', () => {
517
+ const nimbusMd = `## Tool Timeouts\ncloud_discover: 30000\nkubectl_context: 60000\n`;
518
+ const result = parseToolTimeouts(nimbusMd);
519
+ expect(result).toEqual({ cloud_discover: 30000, kubectl_context: 60000 });
520
+ });
521
+
522
+ it('handles Tool Timeouts section at end of file without trailing ##', () => {
523
+ const nimbusMd = `## Project\nMy project.\n\n## Tool Timeouts\nterraform: 450000`;
524
+ const result = parseToolTimeouts(nimbusMd);
525
+ expect(result).toEqual({ terraform: 450000 });
526
+ });
527
+
528
+ it('returns integer values (not floats)', () => {
529
+ const nimbusMd = `## Tool Timeouts\nterraform: 300000\n`;
530
+ const result = parseToolTimeouts(nimbusMd);
531
+ expect(Number.isInteger(result.terraform)).toBe(true);
532
+ });
533
+ });
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // GAP-20 Tests: ToolExecuteContext timeout field
537
+ // ---------------------------------------------------------------------------
538
+
539
+ describe('GAP-20 — ToolExecuteContext.timeout field', () => {
540
+ it('ToolExecuteContext accepts a timeout field', async () => {
541
+ const { z } = await import('zod');
542
+ let capturedCtx: ToolExecuteContext | undefined;
543
+
544
+ const tool: ToolDefinition = {
545
+ name: 'test_timeout_tool',
546
+ description: 'A test tool that captures context',
547
+ inputSchema: z.object({ value: z.string() }),
548
+ permissionTier: 'auto_allow',
549
+ category: 'devops',
550
+ execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
551
+ capturedCtx = ctx;
552
+ return { output: 'ok', isError: false };
553
+ },
554
+ };
555
+
556
+ const ctx: ToolExecuteContext = { timeout: 30000 };
557
+ await tool.execute({ value: 'test' }, ctx);
558
+ expect(capturedCtx?.timeout).toBe(30000);
559
+ });
560
+
561
+ it('ToolExecuteContext.timeout is optional', async () => {
562
+ const { z } = await import('zod');
563
+ let capturedCtx: ToolExecuteContext | undefined;
564
+
565
+ const tool: ToolDefinition = {
566
+ name: 'test_no_timeout_tool',
567
+ description: 'A test tool',
568
+ inputSchema: z.object({ value: z.string() }),
569
+ permissionTier: 'auto_allow',
570
+ category: 'devops',
571
+ execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
572
+ capturedCtx = ctx;
573
+ return { output: 'ok', isError: false };
574
+ },
575
+ };
576
+
577
+ await tool.execute({ value: 'test' }, {});
578
+ expect(capturedCtx?.timeout).toBeUndefined();
579
+ });
580
+
581
+ it('ToolExecuteContext can have both onProgress and timeout', async () => {
582
+ const { z } = await import('zod');
583
+ let capturedCtx: ToolExecuteContext | undefined;
584
+
585
+ const tool: ToolDefinition = {
586
+ name: 'test_full_ctx_tool',
587
+ description: 'A test tool',
588
+ inputSchema: z.object({}),
589
+ permissionTier: 'auto_allow',
590
+ category: 'standard',
591
+ execute: async (_input: unknown, ctx?: ToolExecuteContext) => {
592
+ capturedCtx = ctx;
593
+ return { output: 'ok', isError: false };
594
+ },
595
+ };
596
+
597
+ const chunks: string[] = [];
598
+ const ctx: ToolExecuteContext = {
599
+ onProgress: (chunk) => chunks.push(chunk),
600
+ timeout: 45000,
601
+ };
602
+ await tool.execute({}, ctx);
603
+ expect(capturedCtx?.timeout).toBe(45000);
604
+ expect(capturedCtx?.onProgress).toBeDefined();
605
+ });
606
+ });
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // GAP-20 Tests: toolTimeouts propagation in AgentLoopOptions
610
+ // ---------------------------------------------------------------------------
611
+
612
+ describe('GAP-20 — toolTimeouts in AgentLoopOptions (type check)', () => {
613
+ it('AgentLoopOptions type includes toolTimeouts field', async () => {
614
+ const { readFileSync } = await import('node:fs');
615
+ const { join } = await import('node:path');
616
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
617
+ // Verify the toolTimeouts field is defined in AgentLoopOptions
618
+ expect(loopSrc).toContain('toolTimeouts?: Record<string, number>');
619
+ });
620
+
621
+ it('executeToolCall signature includes toolTimeouts parameter', async () => {
622
+ const { readFileSync } = await import('node:fs');
623
+ const { join } = await import('node:path');
624
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
625
+ // Verify toolTimeouts is a parameter of executeToolCall
626
+ expect(loopSrc).toContain('toolTimeouts?: Record<string, number>');
627
+ // Verify the GAP-20 comment is present
628
+ expect(loopSrc).toContain('GAP-20');
629
+ });
630
+
631
+ it('loop.ts passes options.toolTimeouts to executeToolCall', async () => {
632
+ const { readFileSync } = await import('node:fs');
633
+ const { join } = await import('node:path');
634
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
635
+ expect(loopSrc).toContain('options.toolTimeouts');
636
+ });
637
+
638
+ it('loop.ts builds toolCtx with timeout field', async () => {
639
+ const { readFileSync } = await import('node:fs');
640
+ const { join } = await import('node:path');
641
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
642
+ expect(loopSrc).toContain('toolTimeouts?.[toolName]');
643
+ });
644
+ });
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // GAP-20 Tests: devops.ts DEFAULT_TIMEOUT usage
648
+ // ---------------------------------------------------------------------------
649
+
650
+ describe('GAP-20 — devops.ts DEFAULT_TIMEOUT constant', () => {
651
+ it('DEFAULT_TIMEOUT constant is defined in devops.ts', async () => {
652
+ const { readFileSync } = await import('node:fs');
653
+ const { join } = await import('node:path');
654
+ const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
655
+ expect(devopsSrc).toContain('const DEFAULT_TIMEOUT = 600_000');
656
+ });
657
+
658
+ it('terraform spawnExec uses ctx?.timeout ?? DEFAULT_TIMEOUT', async () => {
659
+ const { readFileSync } = await import('node:fs');
660
+ const { join } = await import('node:path');
661
+ const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
662
+ expect(devopsSrc).toContain('ctx?.timeout ?? DEFAULT_TIMEOUT');
663
+ });
664
+
665
+ it('kubectl spawnExec uses ctx?.timeout override', async () => {
666
+ const { readFileSync } = await import('node:fs');
667
+ const { join } = await import('node:path');
668
+ const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
669
+ // kubectl uses ctx?.timeout ?? defaultKubectlTimeoutMs
670
+ expect(devopsSrc).toContain('ctx?.timeout ?? defaultKubectlTimeoutMs');
671
+ });
672
+
673
+ it('helm spawnExec uses ctx?.timeout ?? DEFAULT_TIMEOUT', async () => {
674
+ const { readFileSync } = await import('node:fs');
675
+ const { join } = await import('node:path');
676
+ const devopsSrc = readFileSync(join(__dirname, '..', 'tools', 'schemas', 'devops.ts'), 'utf-8');
677
+ // There should be at least 2 occurrences of ctx?.timeout ?? DEFAULT_TIMEOUT (terraform + helm)
678
+ const occurrences = (devopsSrc.match(/ctx\?\.timeout \?\? DEFAULT_TIMEOUT/g) ?? []).length;
679
+ expect(occurrences).toBeGreaterThanOrEqual(2);
680
+ });
681
+ });
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // GAP-20 Tests: ink/index.ts parseToolTimeouts integration
685
+ // ---------------------------------------------------------------------------
686
+
687
+ describe('GAP-20 — ink/index.ts parseToolTimeouts integration', () => {
688
+ it('parseToolTimeouts function is defined in ink/index.ts source', async () => {
689
+ const { readFileSync } = await import('node:fs');
690
+ const { join } = await import('node:path');
691
+ const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
692
+ expect(inkSrc).toContain('function parseToolTimeouts');
693
+ });
694
+
695
+ it('ink/index.ts passes toolTimeouts to runAgentLoop', async () => {
696
+ const { readFileSync } = await import('node:fs');
697
+ const { join } = await import('node:path');
698
+ const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
699
+ expect(inkSrc).toContain('toolTimeouts:');
700
+ expect(inkSrc).toContain('parseToolTimeouts(nimbusInstructions)');
701
+ });
702
+
703
+ it('parseToolTimeouts uses the ## Tool Timeouts section regex', async () => {
704
+ const { readFileSync } = await import('node:fs');
705
+ const { join } = await import('node:path');
706
+ const inkSrc = readFileSync(join(__dirname, '..', 'ui', 'ink', 'index.ts'), 'utf-8');
707
+ expect(inkSrc).toContain('Tool Timeouts');
708
+ expect(inkSrc).toContain('[a-z_]+');
709
+ });
710
+ });
711
+
712
+ // ---------------------------------------------------------------------------
713
+ // GAP-11 Tests: loop.ts FileDiff wiring source check
714
+ // ---------------------------------------------------------------------------
715
+
716
+ describe('GAP-11 — loop.ts FileDiff wiring', () => {
717
+ it('loop.ts contains GAP-11 comment for FileDiff trigger', async () => {
718
+ const { readFileSync } = await import('node:fs');
719
+ const { join } = await import('node:path');
720
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
721
+ expect(loopSrc).toContain('GAP-11');
722
+ });
723
+
724
+ it('loop.ts imports parseTerraformPlanOutput from deploy-preview', async () => {
725
+ const { readFileSync } = await import('node:fs');
726
+ const { join } = await import('node:path');
727
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
728
+ expect(loopSrc).toContain('parseTerraformPlanOutput');
729
+ expect(loopSrc).toContain('buildFileDiffBatchFromPlan');
730
+ expect(loopSrc).toContain('./deploy-preview');
731
+ });
732
+
733
+ it('loop.ts checks for terraform plan action before calling FileDiff', async () => {
734
+ const { readFileSync } = await import('node:fs');
735
+ const { join } = await import('node:path');
736
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
737
+ expect(loopSrc).toContain("action === 'plan'");
738
+ expect(loopSrc).toContain('options.requestFileDiff');
739
+ });
740
+
741
+ it('loop.ts breaks on reject-all decision in FileDiff loop', async () => {
742
+ const { readFileSync } = await import('node:fs');
743
+ const { join } = await import('node:path');
744
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
745
+ // The GAP-11 block should break on reject-all
746
+ expect(loopSrc).toContain("decision === 'reject-all'");
747
+ expect(loopSrc).toContain('break');
748
+ });
749
+ });
750
+
751
+ // ---------------------------------------------------------------------------
752
+ // GAP-18 Tests: loop.ts IaC validation wiring source check
753
+ // ---------------------------------------------------------------------------
754
+
755
+ describe('GAP-18 — loop.ts IaC validation wiring', () => {
756
+ it('loop.ts contains GAP-18 comment for terraform validate', async () => {
757
+ const { readFileSync } = await import('node:fs');
758
+ const { join } = await import('node:path');
759
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
760
+ expect(loopSrc).toContain('GAP-18');
761
+ });
762
+
763
+ it('loop.ts checks for .tf file extension', async () => {
764
+ const { readFileSync } = await import('node:fs');
765
+ const { join } = await import('node:path');
766
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
767
+ expect(loopSrc).toContain(".endsWith('.tf')");
768
+ });
769
+
770
+ it('loop.ts runs terraform validate -json', async () => {
771
+ const { readFileSync } = await import('node:fs');
772
+ const { join } = await import('node:path');
773
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
774
+ expect(loopSrc).toContain('terraform validate -json');
775
+ });
776
+
777
+ it('loop.ts checks for write_file, edit_file, multi_edit tool names', async () => {
778
+ const { readFileSync } = await import('node:fs');
779
+ const { join } = await import('node:path');
780
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
781
+ expect(loopSrc).toContain('write_file');
782
+ expect(loopSrc).toContain('edit_file');
783
+ expect(loopSrc).toContain('multi_edit');
784
+ });
785
+
786
+ it('loop.ts appends validation errors to toolContent', async () => {
787
+ const { readFileSync } = await import('node:fs');
788
+ const { join } = await import('node:path');
789
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
790
+ expect(loopSrc).toContain('Terraform validation errors (please fix)');
791
+ });
792
+
793
+ it('loop.ts uses 10 second timeout for validate command', async () => {
794
+ const { readFileSync } = await import('node:fs');
795
+ const { join } = await import('node:path');
796
+ const loopSrc = readFileSync(join(__dirname, '..', 'agent', 'loop.ts'), 'utf-8');
797
+ expect(loopSrc).toContain('timeout: 10_000');
798
+ });
799
+ });
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Integration: parseToolTimeouts with a realistic NIMBUS.md
803
+ // ---------------------------------------------------------------------------
804
+
805
+ describe('GAP-20 — parseToolTimeouts with realistic NIMBUS.md', () => {
806
+ const realisticNimbusMd = `# NIMBUS.md
807
+
808
+ ## Project
809
+ This is an AWS infrastructure project using Terraform and Kubernetes.
810
+
811
+ ## Cloud Context
812
+ - AWS Account: 123456789012
813
+ - Region: us-east-1
814
+
815
+ ## Tool Timeouts
816
+ terraform: 900000
817
+ kubectl: 180000
818
+ helm: 600000
819
+ cloud_discover: 30000
820
+
821
+ ## Instructions
822
+ Always run terraform plan before apply.
823
+ `;
824
+
825
+ it('parses all tool timeouts from a realistic NIMBUS.md', () => {
826
+ const result = parseToolTimeouts(realisticNimbusMd);
827
+ expect(result.terraform).toBe(900000);
828
+ expect(result.kubectl).toBe(180000);
829
+ expect(result.helm).toBe(600000);
830
+ expect(result.cloud_discover).toBe(30000);
831
+ });
832
+
833
+ it('does not include keys from other sections', () => {
834
+ const result = parseToolTimeouts(realisticNimbusMd);
835
+ expect(Object.keys(result)).toHaveLength(4);
836
+ });
837
+
838
+ it('returns correct types (numbers not strings)', () => {
839
+ const result = parseToolTimeouts(realisticNimbusMd);
840
+ for (const value of Object.values(result)) {
841
+ expect(typeof value).toBe('number');
842
+ }
843
+ });
844
+
845
+ it('handles NIMBUS.md with no Tool Timeouts section gracefully', () => {
846
+ const minimal = `# NIMBUS.md\n\n## Project\nSome project.\n`;
847
+ const result = parseToolTimeouts(minimal);
848
+ expect(result).toEqual({});
849
+ });
850
+
851
+ it('handles NIMBUS.md where Tool Timeouts is the last section', () => {
852
+ const lastSection = `# NIMBUS.md\n\n## Project\nSome project.\n\n## Tool Timeouts\nbash: 60000\n`;
853
+ const result = parseToolTimeouts(lastSection);
854
+ expect(result.bash).toBe(60000);
855
+ });
856
+ });
857
+
858
+ // ---------------------------------------------------------------------------
859
+ // H2: Parallel read-only tool dispatch
860
+ // ---------------------------------------------------------------------------
861
+
862
+ describe('parallel read-only tool dispatch (H2)', () => {
863
+ it('loop.ts contains READ_ONLY_TOOLS set', async () => {
864
+ const { readFileSync } = await import('node:fs');
865
+ const { join } = await import('node:path');
866
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
867
+ expect(src).toContain('READ_ONLY_TOOLS');
868
+ });
869
+
870
+ it('READ_ONLY_TOOLS includes cloud_discover and read_file', async () => {
871
+ const { readFileSync } = await import('node:fs');
872
+ const { join } = await import('node:path');
873
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
874
+ expect(src).toContain("'cloud_discover'");
875
+ expect(src).toContain("'read_file'");
876
+ });
877
+
878
+ it('parallel dispatch uses Promise.allSettled', async () => {
879
+ const { readFileSync } = await import('node:fs');
880
+ const { join } = await import('node:path');
881
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
882
+ expect(src).toContain('Promise.allSettled');
883
+ });
884
+
885
+ it('allReadOnly check requires length > 1', async () => {
886
+ const { readFileSync } = await import('node:fs');
887
+ const { join } = await import('node:path');
888
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
889
+ expect(src).toContain('allReadOnly && responseToolCalls.length > 1');
890
+ });
891
+
892
+ it('cloudDiscoverTool schema has regions array field', async () => {
893
+ const { devopsTools } = await import('../tools/schemas/devops');
894
+ const tool = devopsTools.find(t => t.name === 'cloud_discover');
895
+ expect(tool).toBeDefined();
896
+ // Schema should have regions field
897
+ const { readFileSync } = await import('node:fs');
898
+ const { join } = await import('node:path');
899
+ const src = readFileSync(join(process.cwd(), 'src/tools/schemas/devops.ts'), 'utf-8');
900
+ expect(src).toContain('regions: z.array(z.string())');
901
+ });
902
+
903
+ it('parallel dispatch uses continue to skip sequential loop', async () => {
904
+ const { readFileSync } = await import('node:fs');
905
+ const { join } = await import('node:path');
906
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
907
+ expect(src).toContain('Skip sequential processing');
908
+ });
909
+
910
+ it('READ_ONLY_TOOLS includes kubectl_context', async () => {
911
+ const { readFileSync } = await import('node:fs');
912
+ const { join } = await import('node:path');
913
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
914
+ expect(src).toContain("'kubectl_context'");
915
+ });
916
+
917
+ it('READ_ONLY_TOOLS includes helm_values', async () => {
918
+ const { readFileSync } = await import('node:fs');
919
+ const { join } = await import('node:path');
920
+ const src = readFileSync(join(process.cwd(), 'src/agent/loop.ts'), 'utf-8');
921
+ expect(src).toContain("'helm_values'");
922
+ });
923
+ });
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // H1: LIVE streaming indicator
927
+ // ---------------------------------------------------------------------------
928
+
929
+ describe('LIVE streaming indicator (H1)', () => {
930
+ it('ToolCallDisplay has LIVE indicator for logs tool', async () => {
931
+ const { readFileSync } = await import('node:fs');
932
+ const { join } = await import('node:path');
933
+ const src = readFileSync(join(process.cwd(), 'src/ui/ToolCallDisplay.tsx'), 'utf-8');
934
+ expect(src).toContain('● LIVE');
935
+ });
936
+
937
+ it('StatusBar has showStreamingHint prop', async () => {
938
+ const { readFileSync } = await import('node:fs');
939
+ const { join } = await import('node:path');
940
+ const src = readFileSync(join(process.cwd(), 'src/ui/StatusBar.tsx'), 'utf-8');
941
+ expect(src).toContain('showStreamingHint');
942
+ });
943
+
944
+ it('StatusBar shows Esc:stop stream when streaming hint active', async () => {
945
+ const { readFileSync } = await import('node:fs');
946
+ const { join } = await import('node:path');
947
+ const src = readFileSync(join(process.cwd(), 'src/ui/StatusBar.tsx'), 'utf-8');
948
+ expect(src).toContain('Esc:stop stream');
949
+ });
950
+
951
+ it('ToolCallDisplay streaming window is 40 for generic tools (M1: increased from 20)', async () => {
952
+ const { readFileSync } = await import('node:fs');
953
+ const { join } = await import('node:path');
954
+ const src = readFileSync(join(process.cwd(), 'src/ui/ToolCallDisplay.tsx'), 'utf-8');
955
+ // M1: Streaming window was increased — 60 lines for terraform/kubectl/logs, 40 for other tools
956
+ expect(src).toContain('windowSize = isTerraformOrKubectl ? 60 : 40');
957
+ });
958
+ });