alysis-code 0.13.0__py3-none-any.whl

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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2616 @@
1
+ # ruff: noqa: F821
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.markdown import Markdown
12
+ from rich.text import Text
13
+
14
+ from ...config import AppConfig, ConfigError
15
+ from ...forge_completion import build_forge_completion_report
16
+ from ...llm.base import effective_tools_for_client
17
+ from ...personas import is_persona_name, normalize_persona, persona_modes_enabled
18
+ from ...plan_repair import apply_plan_status
19
+ from ...plan_validation import PlannerFailedError, raise_for_execution_ready_plan
20
+ from ...surface.styles import (
21
+ STYLE_CHROME,
22
+ STYLE_EMPHASIS,
23
+ STYLE_SUCCESS,
24
+ STYLE_WARN,
25
+ )
26
+ from ...swarm_orchestrator import acquire_swarm_mutation_guard
27
+ from ...usage_tracker import UsageRecord, aggregate_usage_from_session_logs
28
+ from .state import _ChatExecutionRequest, _ChatPlanModeState, _ForgeChatState
29
+
30
+ _PROTECTED_COMMAND_GLOBAL_NAMES: set[str] = set()
31
+ _SYNCED_COMMAND_GLOBAL_VALUES: dict[str, Any] = {}
32
+ _COMMAND_SYNC_MISSING = object()
33
+ _PATCHABLE_COMMAND_GLOBAL_NAMES: set[str] = {
34
+ "_apply_config_menu_changes_to_session",
35
+ "_is_non_interactive_terminal",
36
+ "load_config",
37
+ }
38
+ _LOGGER = logging.getLogger("alysis_code.cli_impl.chat.commands")
39
+ _TERMINALS_DISPLAY_LINE_LIMIT = 200
40
+ _TERMINALS_USAGE_LINES = (
41
+ "Usage: /terminals",
42
+ " /terminals list",
43
+ " /terminals show <process_id>",
44
+ " /terminals kill <process_id>",
45
+ " /terminals help",
46
+ )
47
+
48
+
49
+ def _merge_usage_payloads_into_session(
50
+ *, session: Any, payloads: list[dict[str, Any]] | tuple[dict[str, Any], ...]
51
+ ) -> int:
52
+ summary = getattr(session, "usage_summary", None)
53
+ add_event_payload = getattr(summary, "add_event_payload", None)
54
+ store = getattr(session, "store", None)
55
+ append = getattr(store, "append", None)
56
+ merged = 0
57
+ for payload in payloads:
58
+ if not isinstance(payload, dict):
59
+ continue
60
+ if str(payload.get("event_type") or "") != "llm_usage":
61
+ continue
62
+ if callable(add_event_payload):
63
+ try:
64
+ add_event_payload(payload)
65
+ except Exception:
66
+ continue
67
+ if callable(append):
68
+ try:
69
+ append("llm_usage", dict(payload))
70
+ except Exception:
71
+ pass
72
+ merged += 1
73
+ return merged
74
+
75
+
76
+ def _merge_usage_records_into_session(
77
+ *, session: Any, records: list[UsageRecord] | tuple[UsageRecord, ...]
78
+ ) -> int:
79
+ summary = getattr(session, "usage_summary", None)
80
+ add_record = getattr(summary, "add_record", None)
81
+ store = getattr(session, "store", None)
82
+ append = getattr(store, "append", None)
83
+ merged = 0
84
+ for record in records:
85
+ if not isinstance(record, UsageRecord):
86
+ continue
87
+ if callable(add_record):
88
+ try:
89
+ add_record(record)
90
+ except Exception:
91
+ continue
92
+ if callable(append):
93
+ try:
94
+ append("llm_usage", record.to_payload())
95
+ except Exception:
96
+ pass
97
+ merged += 1
98
+ return merged
99
+
100
+
101
+ def _merge_forge_execution_usage_into_session(*, session: Any, paths: Any) -> int:
102
+ logs_dir = getattr(paths, "execution_logs_dir", None)
103
+ if logs_dir is None:
104
+ return 0
105
+ try:
106
+ imported = set(getattr(session, "_alysis_imported_forge_usage_logs", set()) or set())
107
+ log_paths = sorted(Path(logs_dir).glob("*.jsonl"))
108
+ except Exception:
109
+ return 0
110
+ selected: list[Path] = []
111
+ selected_keys: list[str] = []
112
+ for path in log_paths:
113
+ try:
114
+ key = os.fspath(path.resolve())
115
+ except Exception:
116
+ key = os.fspath(path)
117
+ if key in imported:
118
+ continue
119
+ selected.append(path)
120
+ selected_keys.append(key)
121
+ if not selected:
122
+ return 0
123
+ try:
124
+ summary = aggregate_usage_from_session_logs(selected)
125
+ records = summary.records()
126
+ except Exception:
127
+ return 0
128
+ merged = _merge_usage_records_into_session(session=session, records=records)
129
+ if merged:
130
+ imported.update(selected_keys)
131
+ try:
132
+ session._alysis_imported_forge_usage_logs = imported
133
+ except Exception:
134
+ pass
135
+ return merged
136
+
137
+
138
+ def _print_alysis_trial_models(console: Console, session: Any, *, current: str) -> None:
139
+ """List the models the hosted Alysis Code trial serves, when on that profile.
140
+
141
+ Best-effort and silent otherwise: only runs when the active profile is the
142
+ hosted `alysis` trial, discovers the live allowlist via the proxy, and
143
+ never raises into the chat loop.
144
+ """
145
+ cfg = getattr(session, "cfg", None)
146
+ if cfg is None:
147
+ return
148
+ try:
149
+ from ...alysis_cloud import PROFILE_KEY
150
+
151
+ active = str((getattr(cfg, "extra_fields", {}) or {}).get("active_profile") or "").strip()
152
+ if active != PROFILE_KEY:
153
+ return
154
+ from ... import account_login
155
+
156
+ models = account_login.list_trial_models(cfg)
157
+ except Exception: # noqa: BLE001 - listing is best-effort
158
+ return
159
+ if not models:
160
+ return
161
+ console.print("Models on your Alysis Code trial:")
162
+ for model_id in models:
163
+ marker = " (current)" if model_id == current else ""
164
+ console.print(f" • {model_id}{marker}")
165
+ console.print("[dim]Switch with: /model <name>[/dim]")
166
+
167
+
168
+ def _print_forge_lock_wait_notice(console: Console, info: dict[str, Any]) -> None:
169
+ diagnostic = str(info.get("diagnostic") or "").strip()
170
+ warnings = ["Another Forge execution is mutating this workspace; queued and waiting."]
171
+ if diagnostic:
172
+ warnings.append(diagnostic)
173
+ _print_forge_warning_messages(
174
+ console=console, label="Forge execution queued", warnings=warnings
175
+ )
176
+
177
+
178
+ def _sync_command_globals(source_globals: dict[str, Any]) -> None:
179
+ module_globals = globals()
180
+ if not _PROTECTED_COMMAND_GLOBAL_NAMES:
181
+ for local_name, local_value in module_globals.items():
182
+ if callable(local_value):
183
+ _PROTECTED_COMMAND_GLOBAL_NAMES.add(local_name)
184
+ for name, value in source_globals.items():
185
+ if name.startswith("__"):
186
+ continue
187
+ if name in _PROTECTED_COMMAND_GLOBAL_NAMES and name not in _PATCHABLE_COMMAND_GLOBAL_NAMES:
188
+ continue
189
+ previous_synced_value = _SYNCED_COMMAND_GLOBAL_VALUES.get(name, _COMMAND_SYNC_MISSING)
190
+ if (
191
+ previous_synced_value is not _COMMAND_SYNC_MISSING
192
+ and module_globals.get(name, _COMMAND_SYNC_MISSING) is not previous_synced_value
193
+ ):
194
+ # Preserve an explicit override applied at the command-module
195
+ # compatibility surface. Without tracking the last value injected
196
+ # by the facade, the next command dispatch silently replaced a
197
+ # monkeypatch after the first command of the process. That made
198
+ # identical classic-chat calls behave differently depending on
199
+ # which command test ran first.
200
+ continue
201
+ module_globals[name] = value
202
+ _SYNCED_COMMAND_GLOBAL_VALUES[name] = value
203
+
204
+
205
+ def _handle_chat_command(
206
+ *,
207
+ input_text: str,
208
+ root: Path,
209
+ session: Any,
210
+ pending_images: list[str],
211
+ console: Console,
212
+ forge_state: _ForgeChatState,
213
+ plan_mode_state: _ChatPlanModeState,
214
+ plan_mode_escape_supported: bool = False,
215
+ plan_mode_action_prompt: Any | None = None,
216
+ forge_planner_reply_sink: Any | None = None,
217
+ forge_execution_report_sink: Any | None = None,
218
+ ) -> str | _ChatExecutionRequest:
219
+ trimmed = input_text.strip()
220
+ if not trimmed:
221
+ return "handled"
222
+
223
+ parts = trimmed.split(maxsplit=1)
224
+ cmd = parts[0].lower()
225
+ arg = parts[1].strip() if len(parts) > 1 else ""
226
+
227
+ if _is_forge_ui_mode(forge_state.ui_mode):
228
+ forge_action = _handle_forge_chat_command(
229
+ input_text=input_text,
230
+ forge_state=forge_state,
231
+ session=session,
232
+ console=console,
233
+ forge_planner_reply_sink=forge_planner_reply_sink,
234
+ forge_execution_report_sink=forge_execution_report_sink,
235
+ )
236
+ if forge_action != "unhandled":
237
+ return forge_action
238
+
239
+ parsed_forge_enter = _parse_forge_enter_command(cmd=cmd, arg=arg)
240
+
241
+ if cmd in {"exit", "quit", "/exit", "/quit"}:
242
+ return "exit"
243
+ if cmd == "/back":
244
+ console.print("Already in chat.")
245
+ return "handled"
246
+ if parsed_forge_enter is not None:
247
+ if parsed_forge_enter.usage_error is not None:
248
+ console.print(f"[red]{parsed_forge_enter.usage_error}[/red]")
249
+ for line in _forge_enter_usage_lines():
250
+ console.print(line)
251
+ return "handled"
252
+ if _is_forge_ui_mode(forge_state.ui_mode):
253
+ console.print("Forge is already active.")
254
+ return "handled"
255
+ forge_state.entry_request_mode = parsed_forge_enter.entry_mode
256
+ forge_state.entry_goal = parsed_forge_enter.initial_goal
257
+ try:
258
+ forge_root = _resolve_forge_entry_root(session=session, fallback_root=root)
259
+ except Exception as e: # noqa: BLE001
260
+ console.print(f"[red]Failed to enter Forge:[/red] {e}")
261
+ return "handled"
262
+ forge_model = getattr(getattr(session, "client", None), "model", None)
263
+ forge_mode = getattr(session, "mode", None)
264
+ enter_kwargs = {
265
+ "root": forge_root,
266
+ "console": console,
267
+ "forge_state": forge_state,
268
+ "model": str(forge_model) if forge_model else None,
269
+ "mode": str(forge_mode) if forge_mode else None,
270
+ }
271
+ if parsed_forge_enter.planner_assistant_default is not None:
272
+ # Explicit choice from the TUI intro popup's "Use the planner?" prompt
273
+ # (or a direct ``/forge --planner`` / ``/forge --no-planner``) wins.
274
+ enter_kwargs["planner_assistant_default"] = parsed_forge_enter.planner_assistant_default
275
+ elif bool(getattr(session, "_alysis_tui_interactive", False)):
276
+ # Bare/goal/resume entry in the alt-screen TUI can't run the classic
277
+ # stdin plan-assistant prompt, so default it on (unchanged behavior).
278
+ enter_kwargs["planner_assistant_default"] = True
279
+ _enter_forge_mode(**enter_kwargs)
280
+ return "handled"
281
+ if cmd == "/":
282
+ console.print(_chat_quick_commands_panel(ui_mode=forge_state.ui_mode))
283
+ return "handled"
284
+ if cmd == "/help":
285
+ console.print(_chat_help_panel(ui_mode=forge_state.ui_mode))
286
+ return "handled"
287
+ if cmd in {"/status"}:
288
+ _print_chat_status(console=console, session=session, pending_images=pending_images)
289
+ return "handled"
290
+ if cmd == "/subagents":
291
+ if arg.strip():
292
+ console.print("[yellow]Usage:[/yellow] /subagents")
293
+ return "handled"
294
+ scheduler = getattr(session, "child_scheduler", None)
295
+ if scheduler is None:
296
+ console.print("no active subagents")
297
+ return "handled"
298
+ try:
299
+ status = scheduler.status()
300
+ except Exception as exc: # noqa: BLE001 - inspection is best-effort
301
+ console.print(f"[red]/subagents failed:[/red] {exc}")
302
+ return "handled"
303
+ raw_children = status.get("children") if isinstance(status, dict) else None
304
+ active_children = [
305
+ child
306
+ for child in (raw_children if isinstance(raw_children, list) else [])
307
+ if isinstance(child, dict)
308
+ and str(child.get("state") or "").strip().lower()
309
+ in {"spawned", "queued", "waiting", "running"}
310
+ ]
311
+ if not active_children:
312
+ console.print("no active subagents")
313
+ return "handled"
314
+ lines: list[str] = []
315
+ for child in active_children:
316
+ lines.append(
317
+ " | ".join(
318
+ (
319
+ str(child.get("subagent") or "subagent"),
320
+ str(child.get("state") or "unknown"),
321
+ str(child.get("workspace_view") or "shared"),
322
+ f"{int(child.get('steps_completed') or 0)} steps",
323
+ str(child.get("activity") or "working"),
324
+ )
325
+ )
326
+ )
327
+ console.print("\n".join(lines))
328
+ return "handled"
329
+ if cmd == "/ask":
330
+ remainder = arg.strip()
331
+ if not remainder:
332
+ console.print("[yellow]Usage:[/yellow] /ask <question> — one read-only turn")
333
+ return "handled"
334
+ current_mode = str(getattr(session, "mode", "review") or "review").strip().lower()
335
+ if current_mode == "readonly":
336
+ return _ChatExecutionRequest(instruction=remainder)
337
+ # One-turn read-only override: the existing mode machinery applies
338
+ # readonly before the turn and restores the previous mode afterwards,
339
+ # exception-safe, so "just look, don't touch" is host-enforced.
340
+ return _ChatExecutionRequest(
341
+ instruction=remainder,
342
+ mode_override="readonly",
343
+ restore_mode_after=current_mode,
344
+ )
345
+ if cmd == "/chat":
346
+ # Retired in favor of the Ask persona; the chat_only plumbing stays
347
+ # accepted-and-ignored for one release (no producer).
348
+ console.print(
349
+ "[yellow]/chat is retired.[/yellow] Use /mode ask for a persistent "
350
+ "read-only Q&A persona, or /ask <question> for one read-only turn."
351
+ )
352
+ return "handled"
353
+ if cmd in {"/terminals"}:
354
+ _handle_terminals_command(arg=arg, session=session, console=console)
355
+ return "handled"
356
+ if cmd in {"/pwd"}:
357
+ _print_chat_pwd(console=console, session=session)
358
+ return "handled"
359
+ if cmd in {"/cd"}:
360
+ requested_path = arg.strip()
361
+ if not requested_path:
362
+ console.print("[yellow]Usage:[/yellow] /cd <path>")
363
+ return "handled"
364
+ try:
365
+ result = set_session_active_workdir(
366
+ session,
367
+ requested_path,
368
+ source="chat_command",
369
+ )
370
+ except Exception as e: # noqa: BLE001
371
+ console.print(f"[red]Failed to change active workdir:[/red] {e}")
372
+ return "handled"
373
+ console.print(
374
+ f"Active workdir: {result['active_workdir']} ({result['active_workdir_relpath']})"
375
+ )
376
+ return "handled"
377
+ if cmd in {"/resume"}:
378
+ cfg = getattr(session, "cfg", None)
379
+ if not isinstance(cfg, AppConfig):
380
+ console.print("[red]Resume unavailable:[/red] missing session config.")
381
+ return "handled"
382
+
383
+ store = getattr(session, "store", None)
384
+ sessions_dir_raw = getattr(store, "sessions_dir", None)
385
+ sessions_dir = (
386
+ Path(sessions_dir_raw) if sessions_dir_raw is not None else resolve_sessions_dir(cfg)
387
+ )
388
+ current_session_id = str(getattr(store, "session_id", "") or "")
389
+
390
+ candidates = _collect_chat_resume_candidates(
391
+ sessions_dir=sessions_dir,
392
+ current_session_id=current_session_id,
393
+ workspace_root=getattr(store, "workspace_root", None),
394
+ git_root=getattr(store, "git_root", None),
395
+ )
396
+ target_session_id: str | None = None
397
+ raw_arg = arg.strip()
398
+ # Only the bare-picker form needs candidates; an explicit
399
+ # "/resume <id>" must still reach the direct-id escape hatch below even
400
+ # when owner/workspace scoping filtered the list down to nothing.
401
+ if not candidates and not raw_arg:
402
+ console.print("No previous sessions available to resume.")
403
+ return "handled"
404
+
405
+ if not raw_arg:
406
+ target_session_id, picker_available = _select_chat_resume_interactive(
407
+ current_session_id=current_session_id,
408
+ sessions=candidates,
409
+ console=console,
410
+ )
411
+ if not picker_available:
412
+ console.print(
413
+ _chat_resume_panel(
414
+ current_session_id=current_session_id,
415
+ sessions=candidates,
416
+ )
417
+ )
418
+ if _is_non_interactive_terminal():
419
+ console.print("Use /resume <index|session_id> to continue a previous session.")
420
+ return "handled"
421
+ try:
422
+ fallback_choice = typer.prompt(
423
+ "Resume session (index/session_id, Enter to cancel)",
424
+ default="",
425
+ ).strip()
426
+ except (EOFError, KeyboardInterrupt):
427
+ console.print("")
428
+ return "handled"
429
+ if not fallback_choice:
430
+ return "handled"
431
+ target_session_id = _resolve_chat_resume_target(
432
+ raw_value=fallback_choice,
433
+ sessions=candidates,
434
+ )
435
+ if target_session_id is None and not fallback_choice.isdigit():
436
+ target_session_id = _resolve_chat_resume_direct_session_id(
437
+ raw_value=fallback_choice,
438
+ sessions_dir=sessions_dir,
439
+ )
440
+ if target_session_id is None:
441
+ console.print(
442
+ "[red]Invalid session.[/red] "
443
+ "Use /resume for picker or /resume <index|session_id>."
444
+ )
445
+ return "handled"
446
+ elif target_session_id is None:
447
+ return "handled"
448
+ else:
449
+ target_session_id = _resolve_chat_resume_target(
450
+ raw_value=raw_arg,
451
+ sessions=candidates,
452
+ )
453
+ if target_session_id is None and not raw_arg.isdigit():
454
+ target_session_id = _resolve_chat_resume_direct_session_id(
455
+ raw_value=raw_arg,
456
+ sessions_dir=sessions_dir,
457
+ )
458
+ if target_session_id is None:
459
+ console.print(
460
+ "[red]Invalid session.[/red] "
461
+ "Use /resume for picker or /resume <index|session_id>."
462
+ )
463
+ return "handled"
464
+
465
+ resumed, message, history_messages = _resume_chat_session(
466
+ session=session,
467
+ target_session_id=target_session_id,
468
+ )
469
+ console.print(message)
470
+ if resumed:
471
+ pending_images.clear()
472
+ # session_start restored the base mode; the persona (and with it
473
+ # the narrowed mode, write scope, and model role) rides on top.
474
+ _reapply_resumed_persona(session=session, console=console)
475
+ _render_chat_resume_history(
476
+ session=session,
477
+ messages=history_messages,
478
+ )
479
+ return "handled"
480
+
481
+ if cmd == "/usage":
482
+ raw_usage_arg = arg.strip()
483
+ if not raw_usage_arg:
484
+ _print_chat_usage(console=console, session=session)
485
+ return "handled"
486
+ parts_usage = raw_usage_arg.split(maxsplit=1)
487
+ if parts_usage[0].strip().lower() != "hud":
488
+ console.print("Usage: /usage (no args = print) or /usage hud on|off|status")
489
+ return "handled"
490
+ usage_arg = parts_usage[1].strip().lower() if len(parts_usage) > 1 else ""
491
+ if not usage_arg:
492
+ usage_arg, picker_available = _select_chat_usage_hud_interactive(
493
+ current_enabled=_chat_usage_hud_enabled(session),
494
+ console=console,
495
+ )
496
+ if not picker_available:
497
+ state = "on" if _chat_usage_hud_enabled(session) else "off"
498
+ console.print(f"Usage HUD: {state}")
499
+ console.print("[dim]Usage: /usage hud on|off|status[/dim]")
500
+ return "handled"
501
+ if usage_arg is None:
502
+ return "handled"
503
+ usage_arg = str(usage_arg).strip().lower()
504
+ if usage_arg == "status":
505
+ state = "on" if _chat_usage_hud_enabled(session) else "off"
506
+ console.print(f"Usage HUD: {state}")
507
+ return "handled"
508
+ parsed = _parse_bool_text(usage_arg)
509
+ if parsed is None:
510
+ console.print("[yellow]Usage:[/yellow] /usage hud on|off|status")
511
+ return "handled"
512
+ _set_chat_usage_hud_enabled(session, parsed)
513
+ console.print(f"Usage HUD set for this session: {'on' if parsed else 'off'}")
514
+ return "handled"
515
+ if cmd in {"/toolbar"}:
516
+ cfg_obj = getattr(session, "cfg", None)
517
+ raw_items = getattr(cfg_obj, "toolbar_items", None) if cfg_obj is not None else None
518
+ active_items: list[str] = []
519
+ seen_items: set[str] = set()
520
+ for raw_item in list(raw_items or list(_DEFAULT_TOOLBAR_ITEMS)):
521
+ item = str(raw_item).strip().lower()
522
+ if not item or item in seen_items or item not in _VALID_TOOLBAR_ITEMS:
523
+ continue
524
+ seen_items.add(item)
525
+ active_items.append(item)
526
+ active_set = set(active_items)
527
+ available_items = [item for item in _CHAT_TOOLBAR_ITEM_ORDER if item not in active_set]
528
+ valid_items_display = ", ".join(_CHAT_TOOLBAR_ITEM_ORDER)
529
+
530
+ toolbar_arg = arg.strip()
531
+ if not toolbar_arg:
532
+ active_display = ", ".join(active_items) if active_items else "(none)"
533
+ available_display = ", ".join(available_items) if available_items else "(none)"
534
+ lines = [
535
+ f"Active: {active_display}",
536
+ f"Available: {available_display}",
537
+ "",
538
+ "/toolbar add <item> add an item",
539
+ "/toolbar remove <item> remove an item",
540
+ "/toolbar reset restore defaults",
541
+ "/toolbar save persist to config",
542
+ ]
543
+ console.print(
544
+ _Panel("\n".join(lines), title="Toolbar Items", border_style="bright_black")
545
+ )
546
+ return "handled"
547
+
548
+ toolbar_parts = toolbar_arg.split(maxsplit=1)
549
+ action = toolbar_parts[0].lower()
550
+ item_arg = toolbar_parts[1].strip().lower() if len(toolbar_parts) > 1 else ""
551
+
552
+ if action == "add":
553
+ if not item_arg:
554
+ console.print("[yellow]Usage:[/yellow] /toolbar add <item>")
555
+ return "handled"
556
+ if item_arg not in _VALID_TOOLBAR_ITEMS:
557
+ console.print(
558
+ f"[red]Invalid toolbar item:[/red] {item_arg}. "
559
+ f"Valid items: {valid_items_display}"
560
+ )
561
+ return "handled"
562
+ if item_arg in active_set:
563
+ console.print(f"Toolbar item already active: {item_arg}")
564
+ return "handled"
565
+ if not isinstance(cfg_obj, AppConfig):
566
+ console.print("[red]Toolbar settings unavailable:[/red] missing session config.")
567
+ return "handled"
568
+ cfg_obj.toolbar_items = [*active_items, item_arg]
569
+ console.print(f"Toolbar item added for this session: {item_arg}")
570
+ return "handled"
571
+
572
+ if action == "remove":
573
+ if not item_arg:
574
+ console.print("[yellow]Usage:[/yellow] /toolbar remove <item>")
575
+ return "handled"
576
+ if item_arg not in _VALID_TOOLBAR_ITEMS:
577
+ console.print(
578
+ f"[red]Invalid toolbar item:[/red] {item_arg}. "
579
+ f"Valid items: {valid_items_display}"
580
+ )
581
+ return "handled"
582
+ if item_arg not in active_set:
583
+ console.print(f"Toolbar item is not active: {item_arg}")
584
+ return "handled"
585
+ if not isinstance(cfg_obj, AppConfig):
586
+ console.print("[red]Toolbar settings unavailable:[/red] missing session config.")
587
+ return "handled"
588
+ cfg_obj.toolbar_items = [item for item in active_items if item != item_arg]
589
+ console.print(f"Toolbar item removed for this session: {item_arg}")
590
+ return "handled"
591
+
592
+ if action == "reset":
593
+ if not isinstance(cfg_obj, AppConfig):
594
+ console.print("[red]Toolbar settings unavailable:[/red] missing session config.")
595
+ return "handled"
596
+ cfg_obj.toolbar_items = list(_DEFAULT_TOOLBAR_ITEMS)
597
+ console.print(
598
+ "Toolbar items reset for this session: "
599
+ + ", ".join(str(item) for item in cfg_obj.toolbar_items)
600
+ )
601
+ return "handled"
602
+
603
+ if action == "save":
604
+ if not isinstance(cfg_obj, AppConfig):
605
+ console.print("[red]Toolbar settings unavailable:[/red] missing session config.")
606
+ return "handled"
607
+ save_config(cfg_obj)
608
+ console.print(f"Saved toolbar settings: {config_path()}")
609
+ return "handled"
610
+
611
+ console.print("[yellow]Usage:[/yellow] /toolbar [add|remove|reset|save]")
612
+ return "handled"
613
+ if cmd == "/skill":
614
+ if _is_forge_ui_mode(forge_state.ui_mode):
615
+ console.print("Skills are unavailable in Forge.")
616
+ return "handled"
617
+ raw_skill_arg = arg.strip()
618
+ enabled, ordered_skills, issues = _session_skill_listing(session)
619
+ if not raw_skill_arg:
620
+ if ordered_skills:
621
+ console.print(
622
+ _skills_table(skills=ordered_skills, title=f"Skills ({len(ordered_skills)})")
623
+ )
624
+ elif enabled:
625
+ console.print("No skills discovered in the supported skill roots.")
626
+ else:
627
+ console.print("Skills are disabled for this session config.")
628
+ for issue in issues:
629
+ source_path = getattr(issue, "source_path", None)
630
+ message = str(getattr(issue, "message", "") or "").strip()
631
+ if source_path is not None and message:
632
+ console.print(f"[yellow]Skipped skill:[/yellow] {source_path} ({message})")
633
+ return "handled"
634
+ registry_obj = getattr(session, "skill_registry", None)
635
+ registry = registry_obj if isinstance(registry_obj, dict) else {}
636
+ if not registry and ordered_skills:
637
+ registry = {
638
+ str(getattr(skill, "name", "")).casefold(): skill for skill in ordered_skills
639
+ }
640
+ parts_skill = raw_skill_arg.split(maxsplit=1)
641
+ skill_name = parts_skill[0].strip()
642
+ skill = resolve_skill_by_name(registry, skill_name)
643
+ catalog_entries_obj = getattr(session, "skill_catalog_entries", ())
644
+ catalog_entries = (
645
+ tuple(item for item in catalog_entries_obj if item is not None)
646
+ if isinstance(catalog_entries_obj, tuple)
647
+ else tuple(item for item in (catalog_entries_obj or []) if item is not None)
648
+ )
649
+ catalog_entry = resolve_skill_catalog_entry(entries=catalog_entries, raw_name=skill_name)
650
+ if skill is None:
651
+ console.print(f"[red]Skill not found:[/red] {skill_name}")
652
+ if ordered_skills:
653
+ console.print(
654
+ "Available skills: "
655
+ + ", ".join(str(getattr(skill_obj, "name", "")) for skill_obj in ordered_skills)
656
+ )
657
+ return "handled"
658
+ if len(parts_skill) == 1 or not parts_skill[1].strip():
659
+ console.print(render_skill_info_text(skill, catalog_entry=catalog_entry))
660
+ return "handled"
661
+ if not enabled:
662
+ console.print("Skills are disabled for this session config.")
663
+ return "handled"
664
+ skill_task = parts_skill[1].strip()
665
+ return _ChatExecutionRequest(
666
+ instruction=skill_task,
667
+ ephemeral_user_messages=(
668
+ build_explicit_skill_context_message(skill=skill, task_text=skill_task),
669
+ ),
670
+ )
671
+ if cmd in {"/context", "/ctx"}:
672
+ _print_chat_context(console=console, session=session)
673
+ return "handled"
674
+ if cmd in {"/report", "/feedback"}:
675
+ cfg = (
676
+ getattr(session, "cfg", None)
677
+ if isinstance(getattr(session, "cfg", None), AppConfig)
678
+ else None
679
+ )
680
+ try:
681
+ result = create_feedback_bundle(
682
+ workspace_root=root,
683
+ feedback_text=arg or None,
684
+ cfg=cfg,
685
+ active_session=session,
686
+ active_run_paths=(
687
+ forge_state.paths if _is_forge_ui_mode(forge_state.ui_mode) else None
688
+ ),
689
+ pending_images=list(pending_images),
690
+ )
691
+ except (ConfigError, FeedbackReportError) as exc:
692
+ console.print(f"[red]Feedback report failed:[/red] {exc}")
693
+ return "handled"
694
+ console.print(f"Feedback bundle directory: {result.bundle_dir}")
695
+ console.print(f"Feedback bundle archive: {result.zip_path}")
696
+ try:
697
+ issue_result = create_feedback_github_issue_draft(
698
+ bundle_result=result,
699
+ feedback_text=arg or None,
700
+ cfg=cfg,
701
+ )
702
+ except Exception as exc: # noqa: BLE001 - GitHub issue drafting is best-effort.
703
+ console.print(f"[yellow]GitHub issue draft skipped:[/yellow] {exc}")
704
+ else:
705
+ for line in feedback_github_issue_status_lines(issue_result):
706
+ console.print(line)
707
+ return "handled"
708
+ if cmd in {"/compact"}:
709
+ if _is_forge_ui_mode(forge_state.ui_mode):
710
+ console.print("Compaction is disabled in Forge.")
711
+ return "handled"
712
+ compactor = getattr(session, "conversation_compactor", None)
713
+ if compactor is None:
714
+ console.print("Compaction unavailable for this session (disabled or not supported).")
715
+ return "handled"
716
+ focus = arg.strip() or None
717
+ messages_obj = getattr(session, "messages", [])
718
+ messages = messages_obj if isinstance(messages_obj, list) else []
719
+ tool_list_obj = getattr(session, "tool_list", None)
720
+ tool_list = tool_list_obj if isinstance(tool_list_obj, list) else None
721
+ tool_list = effective_tools_for_client(getattr(session, "client", None), tool_list)
722
+ tokens_before = _estimate_request_tokens(messages, tool_list)
723
+ state = getattr(compactor, "state", None)
724
+ chunks_before = int(getattr(state, "history_chunk_index", 0) or 0)
725
+ pins_before = len(getattr(state, "pins", []) or [])
726
+ compact_fn = getattr(compactor, "compact_now", None)
727
+ if not callable(compact_fn):
728
+ console.print("Compaction unavailable for this session (missing compact_now).")
729
+ return "handled"
730
+ refresh_calibration = getattr(session, "refresh_compactor_calibration_filters", None)
731
+ if callable(refresh_calibration):
732
+ refresh_calibration()
733
+ new_messages, changed = compact_fn(
734
+ messages=messages,
735
+ tool_list=tool_list,
736
+ main_model=str(getattr(getattr(session, "client", None), "model", "") or ""),
737
+ cache_policy=getattr(
738
+ getattr(session, "client", None),
739
+ "prompt_cache_policy_metadata",
740
+ None,
741
+ ),
742
+ focus=focus,
743
+ )
744
+ if isinstance(new_messages, list):
745
+ session.messages = new_messages
746
+ if changed:
747
+ invalidate_request_context = getattr(session, "invalidate_request_context", None)
748
+ if callable(invalidate_request_context):
749
+ invalidate_request_context(reason="manual_compaction")
750
+ elif hasattr(session, "request_context_measurement"):
751
+ session.request_context_measurement = None
752
+ tokens_after = _estimate_request_tokens(getattr(session, "messages", []), tool_list)
753
+ chunks_after = int(
754
+ getattr(getattr(compactor, "state", None), "history_chunk_index", 0) or 0
755
+ )
756
+ pins_after = len(getattr(getattr(compactor, "state", None), "pins", []) or [])
757
+
758
+ root = Path(getattr(session, "root", Path(".")))
759
+ store_obj = getattr(session, "store", None)
760
+ session_id = str(getattr(store_obj, "session_id", "") or "")
761
+ history_artifact_base = None
762
+ if hasattr(compactor, "history_dir"):
763
+ history_dir_obj = getattr(compactor, "history_dir", None)
764
+ if isinstance(history_dir_obj, Path):
765
+ history_artifact_base = history_dir_obj.parent
766
+ if history_artifact_base is None and session_id:
767
+ history_artifact_base = root / ".alysis" / "sessions" / _safe_component(session_id)
768
+ tool_output_artifact_base = getattr(store_obj, "session_artifact_root", None)
769
+
770
+ table = _Table(title="Compaction")
771
+ table.add_column("field")
772
+ table.add_column("value")
773
+ table.add_row("focus", focus or "-")
774
+ table.add_row("main_model", str(getattr(getattr(session, "client", None), "model", "-")))
775
+ table.add_row(
776
+ "compactor_model",
777
+ str(getattr(getattr(compactor, "compactor_client", None), "model", "-")),
778
+ )
779
+ table.add_row("compaction_profile", str(getattr(compactor, "profile_name", "-")))
780
+ table.add_row("tokens_before", str(tokens_before))
781
+ table.add_row("tokens_after", str(tokens_after))
782
+ table.add_row("tokens_delta", str(tokens_after - tokens_before))
783
+ table.add_row("chunks_created", str(max(0, chunks_after - chunks_before)))
784
+ table.add_row("pins", f"{pins_after} (delta {pins_after - pins_before:+d})")
785
+ table.add_row(
786
+ "history_artifacts_dir",
787
+ _artifact_display_ref(
788
+ root=root,
789
+ store_obj=store_obj,
790
+ artifact_path=history_artifact_base,
791
+ ),
792
+ )
793
+ table.add_row(
794
+ "tool_output_artifacts_dir",
795
+ _artifact_display_ref(
796
+ root=root,
797
+ store_obj=store_obj,
798
+ artifact_path=(
799
+ tool_output_artifact_base / "tool_outputs"
800
+ if isinstance(tool_output_artifact_base, Path)
801
+ else None
802
+ ),
803
+ ),
804
+ )
805
+ console.print(table)
806
+ _refresh_chat_hud_context_cache(session)
807
+ if not changed:
808
+ console.print("Nothing to compact (no eligible history beyond recent window).")
809
+ return "handled"
810
+ if cmd in {"/model-info"}:
811
+ model_name = arg or str(getattr(getattr(session, "client", None), "model", "")).strip()
812
+ if not model_name:
813
+ console.print("Model info unavailable (missing model name).")
814
+ return "handled"
815
+ registry = getattr(session, "model_registry", None)
816
+ if registry is None:
817
+ console.print("Model info unavailable for this session.")
818
+ return "handled"
819
+ meta = registry.get(model_name)
820
+ table = _Table(title=f"Model Info ({model_name})")
821
+ table.add_column("field")
822
+ table.add_column("value")
823
+ field_sources = getattr(meta, "field_sources", {}) or {}
824
+ table.add_row("resolved_model", meta.model_name)
825
+ table.add_row("source", meta.source)
826
+ table.add_row(
827
+ "context_window_tokens",
828
+ (
829
+ f"{meta.context_window_tokens} "
830
+ f"({field_sources.get('context_window_tokens', 'unknown')})"
831
+ ),
832
+ )
833
+ table.add_row(
834
+ "max_output_tokens",
835
+ f"{meta.max_output_tokens} ({field_sources.get('max_output_tokens', 'unknown')})",
836
+ )
837
+ table.add_row(
838
+ "supports_vision",
839
+ f"{meta.supports_vision} ({field_sources.get('supports_vision', 'unknown')})",
840
+ )
841
+ table.add_row(
842
+ "input_cost_per_token",
843
+ f"{meta.input_cost_per_token} ({field_sources.get('input_cost_per_token', 'unknown')})",
844
+ )
845
+ table.add_row(
846
+ "output_cost_per_token",
847
+ (
848
+ f"{meta.output_cost_per_token} "
849
+ f"({field_sources.get('output_cost_per_token', 'unknown')})"
850
+ ),
851
+ )
852
+ table.add_row("registry_last_error", str(getattr(registry, "last_error", None)))
853
+ table.add_row("warnings", "; ".join(getattr(meta, "warnings", ())[:5]) or "-")
854
+ for key, value in _bundled_catalog_provenance_rows(meta=meta, registry=registry):
855
+ table.add_row(key, value)
856
+ raw_keys = sorted(meta.raw_metadata.keys())
857
+ preview_keys = ", ".join(raw_keys[:20]) if raw_keys else "(none)"
858
+ if len(raw_keys) > 20:
859
+ preview_keys += ", ..."
860
+ table.add_row("raw_metadata_keys", preview_keys)
861
+ console.print(table)
862
+ return "handled"
863
+ if cmd in {"/config"}:
864
+ if not arg:
865
+ if _is_non_interactive_terminal():
866
+ console.print(_chat_config_panel(session=session))
867
+ return "handled"
868
+ from ..config_menu import run_config_menu
869
+ from .loop import _ConfigReloadRequiresRestart
870
+
871
+ result = run_config_menu()
872
+ if not result.saved:
873
+ console.print("Closed without saving.")
874
+ return "handled"
875
+ change_count = len(result.changes) + (1 if result.api_key_changed else 0)
876
+ previous_cfg = getattr(session, "cfg", None)
877
+ try:
878
+ from ...profiles import connection_fingerprint
879
+
880
+ reloaded = load_config()
881
+ if previous_cfg is None or connection_fingerprint(
882
+ reloaded
883
+ ) != connection_fingerprint(previous_cfg):
884
+ console.print(
885
+ "[yellow]Model access changed. This session is closing so the old and "
886
+ "new connection states are never mixed. Start `alysis chat` again.[/yellow]"
887
+ )
888
+ return "exit"
889
+ _apply_config_menu_changes_to_session(session=session, cfg=reloaded)
890
+ except _ConfigReloadRequiresRestart as e:
891
+ console.print(
892
+ f"[yellow]Config saved. {e} This session is closing; start "
893
+ "`alysis chat` again.[/yellow]"
894
+ )
895
+ return "exit"
896
+ except Exception as e: # noqa: BLE001
897
+ console.print(f"[red]Config saved, but session reload failed:[/red] {e}")
898
+ return "handled"
899
+ if change_count > 0:
900
+ change_word = "change" if change_count == 1 else "changes"
901
+ console.print(
902
+ f"Saved {change_count} {change_word}. New settings apply on the next user turn."
903
+ )
904
+ else:
905
+ console.print("Config saved (no changes).")
906
+ return "handled"
907
+ if arg.lower() in {"show", "list", "help"}:
908
+ console.print(_chat_config_panel(session=session))
909
+ return "handled"
910
+ parts_config = arg.split()
911
+ action = parts_config[0].lower()
912
+ if action == "set":
913
+ config_key = parts_config[1].lower() if len(parts_config) >= 2 else ""
914
+ if config_key in {"model", "base_url"} and len(parts_config) == 3:
915
+ from ... import config as config_mod
916
+
917
+ try:
918
+ cfg_to_save = config_mod.load_config()
919
+ from ...profiles import connection_fingerprint
920
+
921
+ config_mod.ensure_subscription_menu_managed_key(cfg_to_save, config_key)
922
+ previous_fingerprint = connection_fingerprint(cfg_to_save)
923
+ config_mod.set_config_value(cfg_to_save, config_key, parts_config[2])
924
+ config_mod.save_config(cfg_to_save)
925
+ if connection_fingerprint(cfg_to_save) != previous_fingerprint:
926
+ console.print(
927
+ "[yellow]Model access changed. This session is closing so the old "
928
+ "and new connection states are never mixed. Start `alysis chat` "
929
+ "again.[/yellow]"
930
+ )
931
+ return "exit"
932
+ _apply_config_menu_changes_to_session(session=session, cfg=cfg_to_save)
933
+ except Exception as e: # noqa: BLE001
934
+ console.print(f"[red]Config update failed:[/red] {e}")
935
+ return "handled"
936
+ console.print(f"Saved {config_key}. New setting applies on the next user turn.")
937
+ return "handled"
938
+ if len(parts_config) < 4 or len(parts_config) > 7:
939
+ _print_chat_config_usage(console=console)
940
+ return "handled"
941
+ model_name, model_error = _resolve_chat_model_ref(
942
+ session=session,
943
+ raw_model_ref=parts_config[1],
944
+ )
945
+ if model_error is not None or model_name is None:
946
+ console.print(f"[red]{model_error or 'Invalid model reference.'}[/red]")
947
+ return "handled"
948
+
949
+ context_window_tokens = parse_positive_int(parts_config[2])
950
+ if context_window_tokens is None:
951
+ console.print("[red]Invalid context_window_tokens.[/red] Use a positive integer.")
952
+ return "handled"
953
+
954
+ max_output_tokens = parse_positive_int(parts_config[3])
955
+ if max_output_tokens is None:
956
+ console.print("[red]Invalid max_output_tokens.[/red] Use a positive integer.")
957
+ return "handled"
958
+
959
+ updates: dict[str, Any] = {
960
+ "context_window_tokens": context_window_tokens,
961
+ "max_output_tokens": max_output_tokens,
962
+ }
963
+
964
+ if len(parts_config) >= 5:
965
+ supports_vision = _parse_bool_text(parts_config[4])
966
+ if supports_vision is None:
967
+ console.print("[red]Invalid supports_vision.[/red] Use true/false.")
968
+ return "handled"
969
+ updates["supports_vision"] = supports_vision
970
+
971
+ if len(parts_config) >= 6:
972
+ input_cost = parse_non_negative_float(parts_config[5])
973
+ if input_cost is None:
974
+ console.print(
975
+ "[red]Invalid input_cost_per_token.[/red] Use a non-negative number."
976
+ )
977
+ return "handled"
978
+ updates["input_cost_per_token"] = input_cost
979
+
980
+ if len(parts_config) >= 7:
981
+ output_cost = parse_non_negative_float(parts_config[6])
982
+ if output_cost is None:
983
+ console.print(
984
+ "[red]Invalid output_cost_per_token.[/red] Use a non-negative number."
985
+ )
986
+ return "handled"
987
+ updates["output_cost_per_token"] = output_cost
988
+
989
+ try:
990
+ saved_path, merged = _save_chat_model_metadata_override(
991
+ session=session,
992
+ model_name=model_name,
993
+ fields=updates,
994
+ )
995
+ except ConfigError as e:
996
+ console.print(f"[red]Config error:[/red] {e}")
997
+ return "handled"
998
+ _refresh_chat_hud_context_cache(session)
999
+ console.print(f"Saved model metadata override for {model_name}.")
1000
+ console.print(
1001
+ "context_window_tokens="
1002
+ f"{merged.get('context_window_tokens')} | max_output_tokens="
1003
+ f"{merged.get('max_output_tokens')} | supports_vision="
1004
+ f"{merged.get('supports_vision', 'unset')}"
1005
+ )
1006
+ console.print(f"Config: {saved_path}")
1007
+ return "handled"
1008
+
1009
+ if action in {"clear", "rm", "delete"}:
1010
+ if len(parts_config) != 2:
1011
+ _print_chat_config_usage(console=console)
1012
+ return "handled"
1013
+ model_name, model_error = _resolve_chat_model_ref(
1014
+ session=session,
1015
+ raw_model_ref=parts_config[1],
1016
+ )
1017
+ if model_error is not None or model_name is None:
1018
+ console.print(f"[red]{model_error or 'Invalid model reference.'}[/red]")
1019
+ return "handled"
1020
+ try:
1021
+ saved_path, cleared = _clear_chat_model_metadata_override(
1022
+ session=session,
1023
+ model_name=model_name,
1024
+ )
1025
+ except ConfigError as e:
1026
+ console.print(f"[red]Config error:[/red] {e}")
1027
+ return "handled"
1028
+ if not cleared:
1029
+ console.print(f"No stored override found for {model_name}.")
1030
+ return "handled"
1031
+ _refresh_chat_hud_context_cache(session)
1032
+ console.print(f"Cleared model metadata override for {model_name}.")
1033
+ console.print(f"Config: {saved_path}")
1034
+ return "handled"
1035
+
1036
+ console.print(
1037
+ "[yellow]Unknown /config action.[/yellow] Use /config, /config set, /config clear."
1038
+ )
1039
+ return "handled"
1040
+ if cmd in {"/assets"}:
1041
+ try:
1042
+ run_paths = load_current_run_paths(Path(resolve_session_active_workdir_path(session)))
1043
+ except ForgeError:
1044
+ console.print(
1045
+ "No forge run is active for this workspace. Use /forge plan to start one."
1046
+ )
1047
+ return "handled"
1048
+ _open_assets_modal(session=session, console=console, run_paths=run_paths)
1049
+ return "handled"
1050
+ if cmd in {"/mode"}:
1051
+ current_mode = str(getattr(session, "mode", "review")).strip().lower()
1052
+ next_mode: str | None
1053
+ if not arg:
1054
+ next_mode, picker_available = _select_chat_mode_interactive(
1055
+ current_mode=current_mode,
1056
+ console=console,
1057
+ )
1058
+ if not picker_available:
1059
+ console.print(_chat_mode_panel(current_mode=current_mode))
1060
+ return "handled"
1061
+ if next_mode is None:
1062
+ return "handled"
1063
+ else:
1064
+ if persona_modes_enabled(getattr(session, "cfg", None)) and is_persona_name(
1065
+ arg.strip().lower(), getattr(session, "persona_registry", None)
1066
+ ):
1067
+ # Personas have their own command; keep the two vocabularies
1068
+ # from blurring back together.
1069
+ console.print(f"Personas have their own command: /persona {arg.strip().lower()}")
1070
+ return "handled"
1071
+ next_mode = _resolve_chat_mode_alias(arg)
1072
+ if next_mode is None or next_mode not in _CHAT_MODES:
1073
+ console.print(
1074
+ "[red]Invalid mode.[/red] Try: /mode 1, /mode 2, /mode 3, /mode 4 "
1075
+ "(or safe, fast, read, full)."
1076
+ )
1077
+ return "handled"
1078
+ if _chat_plan_mode_enabled(plan_mode_state):
1079
+ if next_mode == "readonly":
1080
+ console.print("Mode already set: Read-Only (Plan Mode is on)")
1081
+ return "handled"
1082
+ console.print(
1083
+ "Cannot change execution mode while Plan Mode is on. Use /plan off first."
1084
+ )
1085
+ return "handled"
1086
+ if next_mode == current_mode:
1087
+ console.print(f"Mode already set: {_chat_mode_display(next_mode)}")
1088
+ if next_mode == "fullaccess":
1089
+ _print_fullaccess_mode_warning(console=console)
1090
+ return "handled"
1091
+ try:
1092
+ # An explicit execution-mode choice is the user redefining their
1093
+ # base: restore any persona-narrowed write scope BEFORE the
1094
+ # rebuild so the new tool surface reflects the user's own scope.
1095
+ _clear_persona_restore(session)
1096
+ _apply_chat_effective_mode(
1097
+ session=session,
1098
+ next_mode=next_mode,
1099
+ persist_default_mode=True,
1100
+ )
1101
+ except Exception as e: # noqa: BLE001
1102
+ console.print(f"[red]Failed to change mode:[/red] {e}")
1103
+ return "handled"
1104
+ console.print(f"Mode set for this session: {_chat_mode_display(next_mode)}")
1105
+ if next_mode == "fullaccess":
1106
+ _print_fullaccess_mode_warning(console=console)
1107
+ return "handled"
1108
+ if cmd in {"/persona"}:
1109
+ if not persona_modes_enabled(getattr(session, "cfg", None)):
1110
+ console.print(
1111
+ "Persona modes are disabled (persona_modes_enabled=false or "
1112
+ "ALYSIS_PERSONA_MODES=off)."
1113
+ )
1114
+ return "handled"
1115
+ persona_registry = getattr(session, "persona_registry", None)
1116
+ current_persona = normalize_persona(getattr(session, "persona", "code"), persona_registry)
1117
+ persona_selection: str | None
1118
+ if not arg:
1119
+ persona_selection, picker_available = _select_chat_persona_interactive(
1120
+ current_persona=current_persona,
1121
+ console=console,
1122
+ custom=persona_registry,
1123
+ )
1124
+ if not picker_available:
1125
+ console.print(
1126
+ _chat_persona_panel(current_persona=current_persona, custom=persona_registry)
1127
+ )
1128
+ return "handled"
1129
+ if persona_selection is None:
1130
+ return "handled"
1131
+ else:
1132
+ persona_selection = arg.strip().lower()
1133
+ if not is_persona_name(persona_selection, persona_registry):
1134
+ console.print(
1135
+ "[red]Invalid persona.[/red] Try: /persona code, architect, ask, debug"
1136
+ " — or a custom persona from .alysis_personas."
1137
+ )
1138
+ return "handled"
1139
+ # A persona is a convention layered on the gate: the clamp inside
1140
+ # _apply_chat_persona can only keep or lower the execution mode, so
1141
+ # this path needs no fullaccess warning and never persists
1142
+ # default_mode.
1143
+ if _chat_plan_mode_enabled(plan_mode_state):
1144
+ console.print("Cannot change persona while Plan Mode is on. Use /plan off first.")
1145
+ return "handled"
1146
+ persona_name = normalize_persona(persona_selection, persona_registry)
1147
+ if persona_name == current_persona:
1148
+ console.print(f"Persona already set: {persona_name}")
1149
+ return "handled"
1150
+ try:
1151
+ _apply_chat_persona(
1152
+ session=session,
1153
+ persona=persona_name,
1154
+ source="user",
1155
+ )
1156
+ except Exception as e: # noqa: BLE001
1157
+ console.print(f"[red]Failed to change persona:[/red] {e}")
1158
+ return "handled"
1159
+ console.print(f"Persona set for this session: {persona_name}")
1160
+ return "handled"
1161
+ if cmd in {"/plan"}:
1162
+ if _is_forge_ui_mode(forge_state.ui_mode):
1163
+ _print_forge_plan_command_guidance(console=console)
1164
+ return "handled"
1165
+ raw_plan_arg = arg.strip()
1166
+ plan_action, plan_task = _parse_chat_plan_command(raw_plan_arg)
1167
+ if plan_action == "draft":
1168
+ if _chat_plan_mode_enabled(plan_mode_state):
1169
+ for line in _chat_plan_draft_blocked_by_mode_lines(
1170
+ plan_mode_escape_supported=plan_mode_escape_supported
1171
+ ):
1172
+ console.print(line)
1173
+ return "handled"
1174
+ current_mode = str(getattr(session, "mode", "review")).strip().lower()
1175
+ if current_mode == "readonly":
1176
+ for line in _chat_plan_readonly_mode_guidance_lines():
1177
+ console.print(line)
1178
+ return "handled"
1179
+ if not plan_task:
1180
+ try:
1181
+ plan_task = typer.prompt("Plan task", default="").strip()
1182
+ except KeyboardInterrupt:
1183
+ console.print("")
1184
+ return "handled"
1185
+ except (EOFError, typer.Abort):
1186
+ # No interactive stdin available (the TUI feeds an empty/EOF
1187
+ # stdin so interactive prompts cancel cleanly). typer.prompt
1188
+ # raises click.Abort here, whose str() is empty -- if it
1189
+ # escaped it surfaced as a bare "Command error:" with no
1190
+ # detail. Fall through to the usage hint below instead.
1191
+ plan_task = ""
1192
+ if not plan_task:
1193
+ for line in _chat_plan_usage_lines():
1194
+ console.print(line)
1195
+ return "handled"
1196
+ approved_instruction = _run_plan_mode_approval_loop(
1197
+ session=session,
1198
+ console=console,
1199
+ user_message=plan_task,
1200
+ action_prompt=plan_mode_action_prompt,
1201
+ )
1202
+ if approved_instruction is None:
1203
+ return "handled"
1204
+ console.print("")
1205
+ return _ChatExecutionRequest(instruction=approved_instruction)
1206
+ if plan_action == "status":
1207
+ if plan_task:
1208
+ for line in _chat_plan_usage_lines():
1209
+ console.print(line)
1210
+ return "handled"
1211
+ _render_chat_plan_mode_status(console=console, plan_mode_state=plan_mode_state)
1212
+ return "handled"
1213
+ if plan_action == "approve":
1214
+ if plan_task:
1215
+ for line in _chat_plan_usage_lines():
1216
+ console.print(line)
1217
+ return "handled"
1218
+ if not _chat_plan_mode_enabled(plan_mode_state):
1219
+ console.print(
1220
+ "Plan Mode is off. Use /plan <task> for the default draft/review/approve flow that can execute after approval."
1221
+ )
1222
+ console.print(
1223
+ "Use /plan mode only if you explicitly want secondary readonly planning chat."
1224
+ )
1225
+ return "handled"
1226
+ latest_task = _chat_plan_mode_latest_task(plan_mode_state)
1227
+ latest_draft = _chat_plan_mode_latest_draft(plan_mode_state)
1228
+ if latest_task is None or latest_draft is None:
1229
+ for line in _plan_mode_no_stored_draft_lines():
1230
+ console.print(line)
1231
+ if plan_mode_escape_supported:
1232
+ console.print("Press Esc at an empty prompt to leave interactively.")
1233
+ return "handled"
1234
+ restore_mode = _chat_plan_mode_restore_mode(plan_mode_state) or "readonly"
1235
+ if restore_mode == "readonly":
1236
+ for line in _plan_mode_readonly_origin_lines():
1237
+ console.print(line)
1238
+ if plan_mode_escape_supported:
1239
+ console.print("Press Esc at an empty prompt to leave interactively.")
1240
+ return "handled"
1241
+ approved_instruction = instruction_with_approved_plan(
1242
+ user_message=latest_task,
1243
+ approved_plan=latest_draft,
1244
+ )
1245
+ restored = _disable_chat_plan_mode(
1246
+ session=session,
1247
+ console=console,
1248
+ plan_mode_state=plan_mode_state,
1249
+ clear_draft=True,
1250
+ )
1251
+ if restored is None:
1252
+ return "handled"
1253
+ console.print(
1254
+ f"Executing latest stored Plan Mode draft for: {_chat_plan_task_preview(latest_task)}"
1255
+ )
1256
+ return _ChatExecutionRequest(instruction=approved_instruction)
1257
+ if plan_action in {"mode", "readonly", "on"}:
1258
+ if plan_task:
1259
+ for line in _chat_plan_usage_lines():
1260
+ console.print(line)
1261
+ return "handled"
1262
+ parsed = True
1263
+ elif plan_action == "off":
1264
+ if plan_task:
1265
+ for line in _chat_plan_usage_lines():
1266
+ console.print(line)
1267
+ return "handled"
1268
+ parsed = False
1269
+ else:
1270
+ for line in _chat_plan_usage_lines():
1271
+ console.print(line)
1272
+ return "handled"
1273
+ current_mode = str(getattr(session, "mode", "review")).strip().lower() or "review"
1274
+ if parsed:
1275
+ if _chat_plan_mode_enabled(plan_mode_state):
1276
+ console.print(
1277
+ _chat_plan_already_on_message(
1278
+ plan_mode_escape_supported=plan_mode_escape_supported
1279
+ )
1280
+ )
1281
+ return "handled"
1282
+ _clear_chat_plan_mode_draft_state(plan_mode_state)
1283
+ plan_mode_state.enabled = True
1284
+ plan_mode_state.restore_mode = current_mode
1285
+ if current_mode != "readonly":
1286
+ try:
1287
+ _apply_chat_effective_mode(
1288
+ session=session,
1289
+ next_mode="readonly",
1290
+ persist_default_mode=False,
1291
+ )
1292
+ except Exception as e: # noqa: BLE001
1293
+ plan_mode_state.enabled = False
1294
+ plan_mode_state.restore_mode = None
1295
+ console.print(f"[red]Failed to enable Plan Mode:[/red] {e}")
1296
+ return "handled"
1297
+ if plan_mode_escape_supported:
1298
+ console.print(
1299
+ "Plan Mode set for this session: on "
1300
+ "(persistent readonly planning overlay; no execution by itself; press Esc at an empty prompt or use /plan off to leave)"
1301
+ )
1302
+ else:
1303
+ console.print(
1304
+ "Plan Mode set for this session: on (persistent readonly planning overlay; no execution by itself)"
1305
+ )
1306
+ for line in _plan_mode_entry_guidance_lines(restore_mode=current_mode):
1307
+ console.print(line)
1308
+ return "handled"
1309
+ if not _chat_plan_mode_enabled(plan_mode_state):
1310
+ console.print("Plan Mode already off.")
1311
+ return "handled"
1312
+ _disable_chat_plan_mode(
1313
+ session=session,
1314
+ console=console,
1315
+ plan_mode_state=plan_mode_state,
1316
+ clear_draft=True,
1317
+ )
1318
+ return "handled"
1319
+ if cmd in {"/stream"}:
1320
+ stream_arg = arg.strip().lower()
1321
+ if not stream_arg or stream_arg == "status":
1322
+ enabled = bool(getattr(session, "stream", True))
1323
+ console.print(
1324
+ f"Streaming is {'on' if enabled else 'off'}. "
1325
+ "Use /stream on|off or bare /stream in the TUI."
1326
+ )
1327
+ return "handled"
1328
+ stream_value = _parse_bool_text(stream_arg)
1329
+ if stream_value is None:
1330
+ console.print(
1331
+ "[red]Invalid stream value.[/red] Try: /stream on, /stream off, or /stream status."
1332
+ )
1333
+ return "handled"
1334
+ _set_chat_stream_enabled(session=session, enabled=stream_value)
1335
+ console.print(f"Streaming set for this session: {'on' if stream_value else 'off'}")
1336
+ return "handled"
1337
+ if cmd in {"/trace"}:
1338
+ trace_arg = arg.strip()
1339
+ if not trace_arg:
1340
+ next_level, picker_available = _select_chat_trace_interactive(
1341
+ current_level=_chat_trace_level(session),
1342
+ console=console,
1343
+ )
1344
+ if not picker_available:
1345
+ level = _chat_trace_level(session)
1346
+ console.print(
1347
+ f"Reasoning trace: {level}. Usage: /trace off, /trace compact, or /trace full"
1348
+ )
1349
+ return "handled"
1350
+ if next_level is None:
1351
+ return "handled"
1352
+ else:
1353
+ next_level = _resolve_trace_level(trace_arg)
1354
+ if next_level is None:
1355
+ console.print(
1356
+ "[red]Invalid trace level.[/red] Try: /trace off, /trace compact, or /trace full."
1357
+ )
1358
+ return "handled"
1359
+ applied = _set_chat_trace_level(session=session, level=next_level)
1360
+ console.print(f"Reasoning trace set for this session: {applied}")
1361
+ return "handled"
1362
+ if cmd in {"/clear"}:
1363
+ if _is_forge_ui_mode(forge_state.ui_mode):
1364
+ console.print("Cannot /clear inside Forge. Use /back or /done first.")
1365
+ return "handled"
1366
+ try:
1367
+ _clear_chat_conversation(session=session, pending_images=pending_images)
1368
+ except Exception as e: # noqa: BLE001
1369
+ console.print(f"[red]Failed to clear conversation:[/red] {e}")
1370
+ return "handled"
1371
+ console.print("Conversation cleared.")
1372
+ return "handled"
1373
+ if cmd in {"/image"}:
1374
+ if not arg:
1375
+ try:
1376
+ saved = paste_clipboard_image(root=root, output_path=None)
1377
+ except ClipboardError as e:
1378
+ console.print(f"[red]Clipboard error:[/red] {e}")
1379
+ return "handled"
1380
+ pending_images.append(os.fspath(saved))
1381
+ console.print(f"Pasted clipboard image: {saved}")
1382
+ return "handled"
1383
+ pending_images.append(arg)
1384
+ console.print(f"Queued image: {arg}")
1385
+ return "handled"
1386
+ if cmd in {"/paste-image"}:
1387
+ out_path = arg or None
1388
+ try:
1389
+ saved = paste_clipboard_image(root=root, output_path=out_path)
1390
+ except ClipboardError as e:
1391
+ console.print(f"[red]Clipboard error:[/red] {e}")
1392
+ return "handled"
1393
+ pending_images.append(os.fspath(saved))
1394
+ console.print(f"Pasted clipboard image: {saved}")
1395
+ return "handled"
1396
+ if cmd in {"/images"}:
1397
+ if not pending_images:
1398
+ console.print("No queued images.")
1399
+ return "handled"
1400
+ for idx, img in enumerate(pending_images, start=1):
1401
+ console.print(f"{idx}. {img}")
1402
+ return "handled"
1403
+ if cmd in {"/clear-images"}:
1404
+ pending_images.clear()
1405
+ console.print("Cleared queued images.")
1406
+ return "handled"
1407
+ if cmd in {"/model"}:
1408
+ subscription_profile = False
1409
+ try:
1410
+ from ...profiles import get_active_profile
1411
+
1412
+ cfg = getattr(session, "cfg", None)
1413
+ profile = get_active_profile(cfg) if cfg is not None else None
1414
+ subscription_profile = bool(profile is not None and profile.auth_provider)
1415
+ except Exception:
1416
+ subscription_profile = False
1417
+ if not arg:
1418
+ model = getattr(getattr(session, "client", None), "model", "")
1419
+ console.print(f"Current model: {model}")
1420
+ if subscription_profile:
1421
+ console.print(
1422
+ "[dim]Open /config → Default Model to choose from your live "
1423
+ "subscription models and supported reasoning efforts.[/dim]"
1424
+ )
1425
+ else:
1426
+ _print_alysis_trial_models(console, session, current=str(model))
1427
+ return "handled"
1428
+ if subscription_profile:
1429
+ console.print(
1430
+ "[yellow]Subscription model changes are managed in /config so the model and "
1431
+ "reasoning effort stay compatible and persist together.[/yellow]"
1432
+ )
1433
+ return "handled"
1434
+ previous_cfg = getattr(session, "cfg", None)
1435
+ copy_cfg = getattr(previous_cfg, "model_copy", None)
1436
+ if previous_cfg is None or not callable(copy_cfg):
1437
+ console.print(
1438
+ "[yellow]Model was not changed because this session cannot safely refresh "
1439
+ "its provider route. Restart chat with the desired model.[/yellow]"
1440
+ )
1441
+ return "handled"
1442
+ client = getattr(session, "client", None)
1443
+ previous_client_model = getattr(client, "model", None)
1444
+ previous_route_identity = getattr(client, "route_identity", None)
1445
+ candidate_cfg = copy_cfg(update={"model": arg}, deep=True)
1446
+ try:
1447
+ from . import loop as _model_loop
1448
+
1449
+ _model_loop._apply_config_menu_changes_to_session(
1450
+ session=session,
1451
+ cfg=candidate_cfg,
1452
+ )
1453
+ except Exception as exc: # noqa: BLE001 - restore the verified route atomically
1454
+ session.cfg = previous_cfg
1455
+ if client is not None:
1456
+ client.model = previous_client_model
1457
+ if previous_route_identity is not None or hasattr(client, "route_identity"):
1458
+ client.route_identity = previous_route_identity
1459
+ console.print(
1460
+ f"[yellow]Model was not changed because provider capabilities could not be "
1461
+ f"refreshed: {exc}[/yellow]"
1462
+ )
1463
+ return "handled"
1464
+ _refresh_chat_hud_context_cache(session)
1465
+ console.print(f"Model set for this session: {arg}")
1466
+ return "handled"
1467
+ if cmd in {"/login"}:
1468
+ from ..commands.auth import login_connection_interactively, login_connection_rows
1469
+
1470
+ selected = str(arg or "").strip()
1471
+ if not selected:
1472
+ from ..config_menu import _run_config_picker
1473
+
1474
+ rows = login_connection_rows()
1475
+ selected = str(
1476
+ _run_config_picker(
1477
+ console=console,
1478
+ title="Log in",
1479
+ subtitle="Choose how Alysis Code should connect.",
1480
+ rows=rows,
1481
+ current_value=rows[0][0],
1482
+ )
1483
+ or ""
1484
+ ).strip()
1485
+ if not selected:
1486
+ return "handled"
1487
+ previous_cfg = getattr(session, "cfg", None)
1488
+ try:
1489
+ login_connection_interactively(selected, console=console)
1490
+ except typer.Exit:
1491
+ return "handled"
1492
+ try:
1493
+ from ... import config as _login_config
1494
+ from ...profiles import connection_fingerprint
1495
+ from . import loop as _login_loop
1496
+
1497
+ reloaded_cfg = _login_config.load_config()
1498
+ if previous_cfg is None or connection_fingerprint(
1499
+ previous_cfg
1500
+ ) != connection_fingerprint(reloaded_cfg):
1501
+ console.print(
1502
+ "[yellow]Connection saved. This session is closing so the provider "
1503
+ "transport, authentication, and trace capability can be rebuilt safely. "
1504
+ "Start `alysis chat` again.[/yellow]"
1505
+ )
1506
+ return "exit"
1507
+ _login_loop._apply_config_menu_changes_to_session(
1508
+ session=session,
1509
+ cfg=reloaded_cfg,
1510
+ )
1511
+ console.print("[green]The selected connection is active for this session.[/green]")
1512
+ except Exception as exc: # noqa: BLE001 - never keep a possibly stale transport alive
1513
+ console.print(
1514
+ f"[yellow]Connection saved, but live reload failed: {exc}. This session is "
1515
+ "closing; start `alysis chat` again.[/yellow]"
1516
+ )
1517
+ return "exit"
1518
+ return "handled"
1519
+ if cmd in {"/logout"}:
1520
+ from ...account_login import logout as _alysis_logout
1521
+
1522
+ cfg = getattr(session, "cfg", None)
1523
+ if cfg is not None and _alysis_logout(cfg):
1524
+ console.print(
1525
+ "[green]Logged out.[/green] Your Alysis Code key was revoked; "
1526
+ "hosted models will refuse further requests until you /login again."
1527
+ )
1528
+ else:
1529
+ console.print("You're not logged in to an Alysis Code account.")
1530
+ return "handled"
1531
+
1532
+ if cmd[:1] in "/:":
1533
+ suggestion = _suggest_chat_command(parts[0], ui_mode=forge_state.ui_mode)
1534
+ if suggestion:
1535
+ console.print(
1536
+ f"[yellow]Unknown command:[/yellow] {parts[0]}. "
1537
+ f"Did you mean {suggestion}? Try /help."
1538
+ )
1539
+ return "handled"
1540
+ console.print(f"[yellow]Unknown command:[/yellow] {parts[0]}. Try /help.")
1541
+ return "handled"
1542
+
1543
+ if not _is_forge_ui_mode(forge_state.ui_mode):
1544
+ navigation_request = _parse_chat_workdir_navigation_request(
1545
+ input_text=input_text,
1546
+ session=session,
1547
+ )
1548
+ if navigation_request is not None:
1549
+ if len(navigation_request) == 3:
1550
+ _requested_path, _trailing_instruction, error_message = navigation_request
1551
+ console.print(f"[red]Failed to change active workdir:[/red] {error_message}")
1552
+ return "handled"
1553
+
1554
+ requested_path, trailing_instruction = navigation_request
1555
+ try:
1556
+ result = set_session_active_workdir(
1557
+ session,
1558
+ requested_path,
1559
+ source="natural_language_chat",
1560
+ )
1561
+ except Exception as e: # noqa: BLE001
1562
+ console.print(f"[red]Failed to change active workdir:[/red] {e}")
1563
+ return "handled"
1564
+ console.print(
1565
+ f"Active workdir: {result['active_workdir']} ({result['active_workdir_relpath']})"
1566
+ )
1567
+ if not trailing_instruction:
1568
+ return "handled"
1569
+ if _chat_plan_mode_enabled(plan_mode_state):
1570
+ return _resolve_interactive_plan_mode_request(
1571
+ session=session,
1572
+ console=console,
1573
+ plan_mode_state=plan_mode_state,
1574
+ user_message=trailing_instruction,
1575
+ plan_mode_escape_supported=plan_mode_escape_supported,
1576
+ )
1577
+ return _ChatExecutionRequest(instruction=trailing_instruction)
1578
+
1579
+ return "send"
1580
+
1581
+
1582
+ def _handle_terminals_command(*, arg: str, session: Any, console: Console) -> None:
1583
+ parts = arg.split()
1584
+ subcommand = parts[0].lower() if parts else "list"
1585
+
1586
+ if subcommand == "help":
1587
+ _print_terminals_usage(console=console)
1588
+ return
1589
+
1590
+ terminal_manager = getattr(session, "terminal_manager", None)
1591
+ if terminal_manager is None:
1592
+ console.print("Background terminals are unavailable in this session.")
1593
+ return
1594
+
1595
+ try:
1596
+ if subcommand == "list":
1597
+ if len(parts) > 1:
1598
+ console.print("Usage: /terminals list")
1599
+ return
1600
+ _print_terminals_list(console=console, terminal_manager=terminal_manager)
1601
+ return
1602
+
1603
+ if subcommand == "show":
1604
+ if len(parts) != 2:
1605
+ console.print("Usage: /terminals show <process_id>")
1606
+ return
1607
+ _print_terminals_show(
1608
+ console=console,
1609
+ terminal_manager=terminal_manager,
1610
+ process_id=parts[1],
1611
+ )
1612
+ return
1613
+
1614
+ if subcommand == "kill":
1615
+ if len(parts) != 2:
1616
+ console.print("Usage: /terminals kill <process_id>")
1617
+ return
1618
+ mode = str(getattr(session, "mode", "") or "").strip().lower()
1619
+ if mode == "readonly":
1620
+ console.print("Cannot kill processes in readonly mode.")
1621
+ return
1622
+ _print_terminals_kill(
1623
+ console=console,
1624
+ terminal_manager=terminal_manager,
1625
+ process_id=parts[1],
1626
+ )
1627
+ return
1628
+
1629
+ _print_terminals_usage(console=console)
1630
+ except Exception as exc: # noqa: BLE001
1631
+ # Slash commands should report failures without unwinding into the chat loop.
1632
+ _LOGGER.exception("Terminal slash command failed")
1633
+ console.print(f"[red]Background terminal command failed:[/red] {exc}")
1634
+
1635
+
1636
+ def _print_terminals_usage(*, console: Console) -> None:
1637
+ for line in _TERMINALS_USAGE_LINES:
1638
+ console.print(line)
1639
+
1640
+
1641
+ def _print_terminals_list(*, console: Console, terminal_manager: Any) -> None:
1642
+ summaries = terminal_manager.list()
1643
+ if not summaries:
1644
+ console.print("No background processes.")
1645
+ return
1646
+
1647
+ table = _Table(title="Background Terminals")
1648
+ table.add_column("process_id", no_wrap=True)
1649
+ table.add_column("cmd", overflow="fold")
1650
+ table.add_column("status", no_wrap=True)
1651
+ table.add_column("exit_code", no_wrap=True)
1652
+ table.add_column("runtime", no_wrap=True)
1653
+ for summary in summaries:
1654
+ table.add_row(
1655
+ Text(str(summary.process_id)),
1656
+ Text(str(summary.cmd)),
1657
+ Text(str(summary.status)),
1658
+ Text("-" if summary.exit_code is None else str(summary.exit_code)),
1659
+ Text(_format_terminal_runtime_s(float(summary.runtime_s))),
1660
+ )
1661
+ console.print(table)
1662
+
1663
+
1664
+ def _print_terminals_show(
1665
+ *,
1666
+ console: Console,
1667
+ terminal_manager: Any,
1668
+ process_id: str,
1669
+ ) -> None:
1670
+ try:
1671
+ snapshot = terminal_manager.read(process_id, since=0)
1672
+ except KeyError:
1673
+ console.print(_terminal_text("No such background process: ", process_id))
1674
+ return
1675
+
1676
+ console.print(_terminal_text("process_id: ", snapshot.process_id))
1677
+ console.print(_terminal_text("status: ", snapshot.status))
1678
+ console.print(
1679
+ _terminal_text("exit_code: ", "-" if snapshot.exit_code is None else snapshot.exit_code)
1680
+ )
1681
+ if snapshot.failure_reason is not None:
1682
+ console.print(_terminal_text("failure_reason: ", snapshot.failure_reason))
1683
+ console.print(
1684
+ _terminal_text("runtime: ", _format_terminal_runtime_s(float(snapshot.runtime_s)))
1685
+ )
1686
+ console.print(_terminal_text("dropped_lines: ", snapshot.dropped_lines))
1687
+
1688
+ lines = list(snapshot.lines)
1689
+ if len(lines) > _TERMINALS_DISPLAY_LINE_LIMIT:
1690
+ console.print("… (older lines truncated)")
1691
+ lines = lines[-_TERMINALS_DISPLAY_LINE_LIMIT:]
1692
+ if not lines:
1693
+ console.print("No output.")
1694
+ return
1695
+ for line in lines:
1696
+ text = str(line.text).rstrip("\r\n")
1697
+ console.print(_terminal_text(f"{line.seq} {line.stream}: ", text))
1698
+
1699
+
1700
+ def _print_terminals_kill(
1701
+ *,
1702
+ console: Console,
1703
+ terminal_manager: Any,
1704
+ process_id: str,
1705
+ ) -> None:
1706
+ try:
1707
+ snapshot = terminal_manager.kill(process_id)
1708
+ except KeyError:
1709
+ console.print(_terminal_text("No such background process: ", process_id))
1710
+ return
1711
+ code = "-" if snapshot.exit_code is None else str(snapshot.exit_code)
1712
+ console.print(
1713
+ Text.assemble(
1714
+ ("Killed ",),
1715
+ (process_id,),
1716
+ (" (status=",),
1717
+ (snapshot.status,),
1718
+ (", exit_code=",),
1719
+ (code,),
1720
+ (")",),
1721
+ )
1722
+ )
1723
+
1724
+
1725
+ def _terminal_text(prefix: str, value: object) -> Text:
1726
+ return Text.assemble((prefix,), (str(value),))
1727
+
1728
+
1729
+ def _format_terminal_runtime_s(runtime_s: float) -> str:
1730
+ if runtime_s < 0:
1731
+ runtime_s = 0.0
1732
+ if runtime_s < 60:
1733
+ return f"{runtime_s:.1f}s"
1734
+ minutes = int(runtime_s // 60)
1735
+ seconds = int(runtime_s % 60)
1736
+ return f"{minutes}m{seconds:02d}s"
1737
+
1738
+
1739
+ def _handle_forge_chat_command(
1740
+ *,
1741
+ input_text: str,
1742
+ forge_state: _ForgeChatState,
1743
+ session: Any,
1744
+ console: Console,
1745
+ forge_planner_reply_sink: Any | None = None,
1746
+ forge_execution_report_sink: Any | None = None,
1747
+ ) -> str:
1748
+ paths = forge_state.paths
1749
+ plan = forge_state.plan
1750
+ if paths is None or plan is None:
1751
+ _print_forge_warning_messages(
1752
+ console=console,
1753
+ label="Forge",
1754
+ warnings=["Forge state missing; returning to chat mode."],
1755
+ )
1756
+ forge_state.ui_mode = "chat"
1757
+ return "handled"
1758
+
1759
+ trimmed = input_text.strip()
1760
+ if not trimmed:
1761
+ return "handled"
1762
+
1763
+ parts = trimmed.split(maxsplit=1)
1764
+ cmd = parts[0].lower()
1765
+ arg = parts[1].strip() if len(parts) > 1 else ""
1766
+
1767
+ if cmd in {"/back"}:
1768
+ forge_state.ui_mode = "chat"
1769
+ append_transcript_note(paths, role="system", message="Exited Forge with /back.")
1770
+ _print_forge_meta(
1771
+ console=console,
1772
+ message="Left Forge without the final save/validate step. Back to chat.",
1773
+ )
1774
+ return "handled"
1775
+
1776
+ if cmd in {"/done", "done"}:
1777
+ append_transcript_note(paths, role="user", message=trimmed)
1778
+ append_transcript_note(paths, role="system", message="Forge finalized with /done.")
1779
+ _finalize_forge_plan(
1780
+ console=console,
1781
+ paths=paths,
1782
+ plan=plan,
1783
+ transcript_tail=forge_state.planner_transcript,
1784
+ )
1785
+ forge_state.ui_mode = "chat"
1786
+ _print_forge_meta(
1787
+ console=console,
1788
+ message="Saved and validated the plan. Back to chat.",
1789
+ )
1790
+ return "handled"
1791
+
1792
+ if cmd in {"/help"}:
1793
+ console.print(_chat_help_panel(ui_mode="forge"))
1794
+ return "handled"
1795
+
1796
+ if cmd in {"/show"}:
1797
+ _show_forge_plan_summary(console=console, paths=paths, plan=plan)
1798
+ return "handled"
1799
+
1800
+ if cmd in {"/assets"}:
1801
+ _open_assets_modal(session=session, console=console, run_paths=paths)
1802
+ return "handled"
1803
+
1804
+ if cmd in {"/plan"}:
1805
+ append_transcript_note(paths, role="user", message=trimmed)
1806
+ plan_cmd = arg.strip().lower()
1807
+ if not plan_cmd:
1808
+ plan_cmd, picker_available = _select_forge_plan_command_interactive(console=console)
1809
+ if not picker_available:
1810
+ _print_forge_warning_messages(
1811
+ console=console,
1812
+ label="Usage",
1813
+ warnings=["/plan tasks|table|markdown|md|edit"],
1814
+ )
1815
+ return "handled"
1816
+ if plan_cmd is None:
1817
+ return "handled"
1818
+
1819
+ if plan_cmd in {"view", "tasks", "table"}:
1820
+ _show_forge_plan_summary(console=console, paths=paths, plan=plan)
1821
+ return "handled"
1822
+ if plan_cmd in {"markdown", "md"}:
1823
+ _show_forge_plan_markdown(console=console, paths=paths, plan=plan)
1824
+ return "handled"
1825
+ if plan_cmd in {"edit", "edit-json"}:
1826
+ _edit_forge_plan_json(console=console, paths=paths, forge_state=forge_state)
1827
+ updated_plan = forge_state.plan
1828
+ if isinstance(updated_plan, dict):
1829
+ plan = updated_plan
1830
+ return "handled"
1831
+ append_transcript_note(
1832
+ paths,
1833
+ role="system",
1834
+ message="Rejected invalid Forge /plan usage.",
1835
+ )
1836
+ _print_forge_plan_command_guidance(console=console)
1837
+ return "handled"
1838
+
1839
+ if cmd in {"/execute"}:
1840
+ forge_state.swarm_run_attempted = False
1841
+ if arg.strip().lower() != "plan":
1842
+ append_transcript_note(paths, role="user", message=trimmed)
1843
+ append_transcript_note(
1844
+ paths,
1845
+ role="system",
1846
+ message="Rejected invalid /execute usage.",
1847
+ )
1848
+ _print_forge_warning_messages(
1849
+ console=console,
1850
+ label="Usage",
1851
+ warnings=["/execute plan"],
1852
+ )
1853
+ return "handled"
1854
+
1855
+ if not _forge_has_usable_plan_input(plan):
1856
+ _print_forge_warning_messages(
1857
+ console=console,
1858
+ label="Forge",
1859
+ warnings=[
1860
+ "Plan is empty. Add requirements or tasks first (paste text, /task, or /plan edit)."
1861
+ ],
1862
+ )
1863
+ append_transcript_note(paths, role="user", message=trimmed)
1864
+ append_transcript_note(
1865
+ paths,
1866
+ role="system",
1867
+ message="Rejected /execute plan because plan had no requirements/tasks.",
1868
+ )
1869
+ return "handled"
1870
+
1871
+ try:
1872
+ run_mutation_guard = acquire_swarm_mutation_guard(
1873
+ paths,
1874
+ mode="forge_swarm:chat",
1875
+ on_wait=lambda info: _print_forge_lock_wait_notice(console, info),
1876
+ )
1877
+ except ForgeError as e:
1878
+ _print_forge_error(
1879
+ console=console,
1880
+ message=f"Forge execution failed: {e}",
1881
+ )
1882
+ return "handled"
1883
+
1884
+ try:
1885
+ append_transcript_note(paths, role="user", message=trimmed)
1886
+
1887
+ if bool(getattr(run_mutation_guard, "acquired_after_wait", False)):
1888
+ plan.clear()
1889
+ plan.update(load_plan(paths))
1890
+ workspace_context = _workspace_context_payload_for_paths(
1891
+ paths=paths,
1892
+ refresh_if_stale=True,
1893
+ )
1894
+ finalize_plan(plan)
1895
+ reconciliation_result, workspace_context = _reconcile_plan_for_paths(
1896
+ paths=paths,
1897
+ plan=plan,
1898
+ refresh_if_stale=False,
1899
+ transcript_tail=forge_state.planner_transcript,
1900
+ )
1901
+ save_plan(paths, plan)
1902
+ validation_warnings = _validate_forge_plan_for_paths(paths, plan)
1903
+ if reconciliation_result.warnings:
1904
+ _print_forge_warning_messages(
1905
+ console=console,
1906
+ label="Plan reconciliation",
1907
+ warnings=list(reconciliation_result.warnings),
1908
+ )
1909
+ for warning in reconciliation_result.warnings:
1910
+ append_transcript_note(
1911
+ paths,
1912
+ role="system",
1913
+ message=f"Plan reconciliation warning: {warning}",
1914
+ )
1915
+ _write_plan_validation_artifact(
1916
+ paths=paths,
1917
+ reconciliation_result=reconciliation_result,
1918
+ validation_warnings=validation_warnings,
1919
+ )
1920
+ no_execution_ready_tasks_message = _forge_no_execution_ready_tasks_message(plan)
1921
+ if no_execution_ready_tasks_message is not None:
1922
+ _print_forge_error(
1923
+ console=console,
1924
+ message=no_execution_ready_tasks_message,
1925
+ )
1926
+ append_transcript_note(
1927
+ paths,
1928
+ role="system",
1929
+ message=f"Rejected /execute plan: {no_execution_ready_tasks_message}",
1930
+ )
1931
+ return "handled"
1932
+
1933
+ enrich_attempted = False
1934
+ enrich_applied = False
1935
+ enrich_summary = "enrichment not needed"
1936
+ show_enrichment_details = _chat_trace_level(session) == "full"
1937
+ if _forge_should_try_enrichment(validation_warnings):
1938
+ if not _forge_enrich_plan_enabled():
1939
+ enrich_summary = "enrichment skipped: disabled"
1940
+ else:
1941
+ planner_api_key = str(
1942
+ getattr(getattr(session, "client", None), "api_key", "") or ""
1943
+ ).strip()
1944
+ session_cfg = getattr(session, "cfg", None)
1945
+ if planner_api_key and isinstance(session_cfg, AppConfig):
1946
+ enrich_attempted = True
1947
+ enrichment_user_text = (
1948
+ "Enrich the existing plan for execution: do not ask questions; only fill "
1949
+ "missing acceptance_criteria and estimated_files via tasks_update; do not "
1950
+ "change project_goal, summary, requirements, tasks_add, or tasks_remove."
1951
+ )
1952
+ planner_knowledge = prepare_planner_knowledge(
1953
+ paths=paths,
1954
+ plan=plan,
1955
+ user_text=enrichment_user_text,
1956
+ selection_label="planner_enrichment",
1957
+ )
1958
+ planner_workspace_root = resolve_knowledge_workspace_root(paths)
1959
+ planner_result = run_planner_turn(
1960
+ cfg=clone_cfg(session_cfg),
1961
+ api_key_override=planner_api_key,
1962
+ plan=plan,
1963
+ transcript_tail=forge_state.planner_transcript,
1964
+ workspace_context=workspace_context,
1965
+ user_text=enrichment_user_text,
1966
+ relevant_knowledge_section=planner_knowledge.render_prompt_section(
1967
+ workspace_root=planner_workspace_root
1968
+ ),
1969
+ run_paths=paths,
1970
+ )
1971
+ _merge_usage_payloads_into_session(
1972
+ session=session,
1973
+ payloads=list(getattr(planner_result, "usage_events", []) or []),
1974
+ )
1975
+ request_retry_count = int(
1976
+ getattr(planner_result, "request_retry_count", 0) or 0
1977
+ )
1978
+ if request_retry_count > 0 and not planner_result.error:
1979
+ retry_word = "retry" if request_retry_count == 1 else "retries"
1980
+ retry_notice = (
1981
+ f"Planner request recovered after {request_retry_count} transient "
1982
+ f"{retry_word}."
1983
+ )
1984
+ if show_enrichment_details:
1985
+ _print_forge_warning_messages(
1986
+ console=console,
1987
+ label="Plan enrichment",
1988
+ warnings=[retry_notice],
1989
+ )
1990
+ if show_enrichment_details:
1991
+ append_transcript_note(
1992
+ paths,
1993
+ role="system",
1994
+ message=f"Plan enrichment warning: {retry_notice}",
1995
+ )
1996
+ if planner_result.error:
1997
+ retry_word = "retry" if request_retry_count == 1 else "retries"
1998
+ enrichment_error_summary = (
1999
+ f"enrichment skipped after {request_retry_count} transient "
2000
+ f"{retry_word}: {planner_result.error}"
2001
+ if request_retry_count > 0
2002
+ else f"enrichment skipped: {planner_result.error}"
2003
+ )
2004
+ enrichment_error_warnings = (
2005
+ [
2006
+ f"Planner request failed after {request_retry_count} transient "
2007
+ f"{retry_word}."
2008
+ ]
2009
+ if request_retry_count > 0
2010
+ else []
2011
+ )
2012
+ enrichment_error_warnings.append(
2013
+ f"Final planner error: {planner_result.error}"
2014
+ )
2015
+ _print_forge_warning_messages(
2016
+ console=console,
2017
+ label="Plan enrichment",
2018
+ warnings=enrichment_error_warnings,
2019
+ )
2020
+ append_transcript_note(
2021
+ paths,
2022
+ role="system",
2023
+ message=(
2024
+ f"Plan enrichment error after {request_retry_count} transient "
2025
+ f"{retry_word}: {planner_result.error}"
2026
+ if request_retry_count > 0
2027
+ else f"Plan enrichment error: {planner_result.error}"
2028
+ ),
2029
+ )
2030
+ enrich_summary = enrichment_error_summary
2031
+ elif planner_result.plan_update:
2032
+ sanitized_enrichment = _sanitize_forge_enrichment_plan_update(
2033
+ plan=plan,
2034
+ plan_update=planner_result.plan_update,
2035
+ )
2036
+ if sanitized_enrichment.warnings:
2037
+ if show_enrichment_details:
2038
+ _print_forge_warning_messages(
2039
+ console=console,
2040
+ label="Plan enrichment",
2041
+ warnings=list(sanitized_enrichment.warnings),
2042
+ )
2043
+ if show_enrichment_details:
2044
+ for warning in sanitized_enrichment.warnings:
2045
+ append_transcript_note(
2046
+ paths,
2047
+ role="system",
2048
+ message=f"Plan enrichment warning: {warning}",
2049
+ )
2050
+ if sanitized_enrichment.plan_update is None:
2051
+ enrich_summary = "enrichment produced no applicable changes"
2052
+ else:
2053
+ apply_result = apply_guarded_planner_plan_update(
2054
+ plan,
2055
+ sanitized_enrichment.plan_update,
2056
+ latest_user_text=enrichment_user_text,
2057
+ workspace_context=workspace_context,
2058
+ )
2059
+ reconciliation_target_ids = list(
2060
+ dict.fromkeys(
2061
+ [
2062
+ *apply_result.added_task_ids,
2063
+ *apply_result.updated_task_ids,
2064
+ ]
2065
+ )
2066
+ )
2067
+ if apply_result.warnings:
2068
+ if show_enrichment_details:
2069
+ _print_forge_warning_messages(
2070
+ console=console,
2071
+ label="Plan enrichment",
2072
+ warnings=list(apply_result.warnings),
2073
+ )
2074
+ if show_enrichment_details:
2075
+ for warning in apply_result.warnings:
2076
+ append_transcript_note(
2077
+ paths,
2078
+ role="system",
2079
+ message=f"Plan enrichment warning: {warning}",
2080
+ )
2081
+ if apply_result.changed:
2082
+ enrich_applied = True
2083
+ reconciliation_result, workspace_context = (
2084
+ _reconcile_plan_for_paths(
2085
+ paths=paths,
2086
+ plan=plan,
2087
+ refresh_if_stale=False,
2088
+ transcript_tail=forge_state.planner_transcript,
2089
+ target_task_ids=reconciliation_target_ids,
2090
+ )
2091
+ )
2092
+ save_plan(paths, plan)
2093
+ validation_warnings = _validate_forge_plan_for_paths(
2094
+ paths, plan
2095
+ )
2096
+ _write_plan_validation_artifact(
2097
+ paths=paths,
2098
+ reconciliation_result=reconciliation_result,
2099
+ validation_warnings=validation_warnings,
2100
+ )
2101
+ enrich_summary = (
2102
+ f"enrichment applied: {summarize_plan_update(apply_result)}"
2103
+ )
2104
+ else:
2105
+ enrich_summary = "enrichment produced no applicable changes"
2106
+ else:
2107
+ enrich_summary = "enrichment produced no plan_update"
2108
+ else:
2109
+ enrich_summary = "enrichment skipped: missing planner api key/config"
2110
+
2111
+ append_planner_summary(paths, enrich_summary)
2112
+ if enrich_applied:
2113
+ _print_forge_meta(console=console, message="Refined plan.")
2114
+
2115
+ if validation_warnings:
2116
+ _print_forge_warning_messages(
2117
+ console=console,
2118
+ label="Plan validation",
2119
+ warnings=validation_warnings,
2120
+ )
2121
+ for warning in validation_warnings:
2122
+ append_transcript_note(
2123
+ paths,
2124
+ role="system",
2125
+ message=f"Plan validation warning: {warning}",
2126
+ )
2127
+ elif (
2128
+ enrich_attempted
2129
+ and enrich_applied
2130
+ and not validation_warnings
2131
+ and show_enrichment_details
2132
+ ):
2133
+ append_transcript_note(
2134
+ paths,
2135
+ role="system",
2136
+ message="Plan enrichment cleared the remaining validation warnings.",
2137
+ )
2138
+
2139
+ # Stamp draft/execution_ready before the gate runs, so the plan on disk
2140
+ # already says what the gate is about to decide.
2141
+ status_assessment = apply_plan_status(plan, validation_warnings=validation_warnings)
2142
+ save_plan(paths, plan)
2143
+
2144
+ try:
2145
+ raise_for_execution_ready_plan(plan)
2146
+ except PlannerFailedError as e:
2147
+ block_message = str(e)
2148
+ _print_forge_error(
2149
+ console=console,
2150
+ message=block_message,
2151
+ )
2152
+ _print_forge_meta(
2153
+ console=console,
2154
+ message=(
2155
+ "This plan is saved as a draft, not an execution-ready plan. "
2156
+ "Refine it with the planner or edit it, then run /execute plan again."
2157
+ ),
2158
+ )
2159
+ append_transcript_note(
2160
+ paths,
2161
+ role="system",
2162
+ message=(
2163
+ f"Rejected /execute plan (plan_status={status_assessment.status}): "
2164
+ f"{block_message}"
2165
+ ),
2166
+ )
2167
+ return "handled"
2168
+
2169
+ session_cfg = getattr(session, "cfg", None)
2170
+ if not isinstance(session_cfg, AppConfig):
2171
+ _print_forge_error(
2172
+ console=console,
2173
+ message="Forge execution failed: missing session config.",
2174
+ )
2175
+ return "handled"
2176
+ swarm_cfg = clone_cfg(session_cfg)
2177
+ client_obj = getattr(session, "client", None)
2178
+ api_key_override = str(getattr(client_obj, "api_key", "") or "").strip() or None
2179
+ store_obj = getattr(session, "store", None)
2180
+ no_log = not bool(getattr(store_obj, "enabled", True))
2181
+ yes_value = bool(getattr(session, "yes", False))
2182
+ swarm_trace_level = _chat_trace_level(session)
2183
+ swarm_trace_sink = _make_forge_swarm_trace_sink(
2184
+ session=session,
2185
+ paths=paths,
2186
+ console=console,
2187
+ )
2188
+
2189
+ # Swarm knobs: the TUI launch gate may have chosen non-default values
2190
+ # (stored on ``forge_state.swarm_knobs``); the classic/non-TUI path
2191
+ # leaves it empty so the historical defaults (parallel 2 · scope strict
2192
+ # · verify warn · no review) apply unchanged. Every value is validated
2193
+ # and clamped here so a malformed entry can never reach ``run_swarm``.
2194
+ _knobs = getattr(forge_state, "swarm_knobs", None) or {}
2195
+ try:
2196
+ _parallel = int(_knobs.get("parallel", 2))
2197
+ except (TypeError, ValueError):
2198
+ _parallel = 2
2199
+ _parallel = max(1, min(_parallel, 8))
2200
+ _scope_mode = str(_knobs.get("scope_mode", "strict")).strip().lower()
2201
+ if _scope_mode not in {"strict", "warn"}:
2202
+ _scope_mode = "strict"
2203
+ _verify_mode = str(_knobs.get("verify_mode", "warn")).strip().lower()
2204
+ if _verify_mode not in {"warn", "strict", "off"}:
2205
+ _verify_mode = "warn"
2206
+ _review = bool(_knobs.get("review", False))
2207
+
2208
+ forge_state.swarm_run_attempted = True
2209
+ try:
2210
+ code = run_swarm(
2211
+ paths=paths,
2212
+ plan=plan,
2213
+ cfg=swarm_cfg,
2214
+ mode="auto",
2215
+ yes=yes_value,
2216
+ max_steps=None,
2217
+ api_key_override=api_key_override,
2218
+ no_log=no_log,
2219
+ parallel=_parallel,
2220
+ base_branch=None,
2221
+ max_tasks=None,
2222
+ max_attempts=None,
2223
+ dry_run=False,
2224
+ keep_worktrees=False,
2225
+ retry_failed=False,
2226
+ retry_changes_requested=False,
2227
+ only=None,
2228
+ retry_merge_conflicts=False,
2229
+ scope_mode=_scope_mode,
2230
+ verify_mode=_verify_mode,
2231
+ integration_mode=None,
2232
+ verify_cmd=None,
2233
+ review=_review,
2234
+ console=console,
2235
+ trace_level=swarm_trace_level,
2236
+ trace_sink=swarm_trace_sink,
2237
+ run_mutation_guard=run_mutation_guard,
2238
+ )
2239
+ except (ConfigError, ForgeError, GitOpsError) as e:
2240
+ _print_forge_error(
2241
+ console=console,
2242
+ message=f"Forge execution failed: {e}",
2243
+ )
2244
+ append_transcript_note(
2245
+ paths,
2246
+ role="system",
2247
+ message=f"Forge /execute plan failed: {e}",
2248
+ )
2249
+ return "handled"
2250
+ except Exception as e: # noqa: BLE001
2251
+ _print_forge_error(
2252
+ console=console,
2253
+ message=f"Forge execution failed: {e}",
2254
+ )
2255
+ append_transcript_note(
2256
+ paths,
2257
+ role="system",
2258
+ message=f"Forge /execute plan failed: {e}",
2259
+ )
2260
+ return "handled"
2261
+
2262
+ _merge_forge_execution_usage_into_session(session=session, paths=paths)
2263
+ summary_path = paths.execution_dir / "swarm_summary.md"
2264
+ summary_json_path = paths.execution_dir / "swarm_summary.json"
2265
+ run_status = ""
2266
+ run_clean: bool | None = None
2267
+ if summary_json_path.exists():
2268
+ try:
2269
+ summary_payload = json.loads(summary_json_path.read_text(encoding="utf-8"))
2270
+ run_status = str(summary_payload.get("status") or "").strip()
2271
+ raw_clean = summary_payload.get("clean")
2272
+ if isinstance(raw_clean, bool):
2273
+ run_clean = raw_clean
2274
+ except Exception: # noqa: BLE001
2275
+ run_status = ""
2276
+ try:
2277
+ plan_after_execution = load_plan(paths)
2278
+ except Exception: # noqa: BLE001
2279
+ plan_after_execution = plan
2280
+ total_tasks = len(plan_after_execution.get("tasks") or [])
2281
+ done_tasks, failed_tasks, remaining_tasks = _forge_task_status_counts(
2282
+ plan_after_execution
2283
+ )
2284
+ if total_tasks <= 0:
2285
+ headline = "Execution finished."
2286
+ elif run_clean is False and run_status:
2287
+ headline = (
2288
+ f"Execution finished with verification status `{run_status}` · "
2289
+ f"{done_tasks} done · {failed_tasks} failed · {remaining_tasks} remaining."
2290
+ )
2291
+ elif failed_tasks == 0 and remaining_tasks == 0 and code == 0:
2292
+ headline = f"Execution complete · {total_tasks} tasks finished."
2293
+ elif failed_tasks == 0:
2294
+ headline = (
2295
+ f"Execution finished · {done_tasks} done · {remaining_tasks} still in progress."
2296
+ )
2297
+ elif done_tasks > 0:
2298
+ headline = (
2299
+ f"Execution finished with issues · {done_tasks} done · "
2300
+ f"{failed_tasks} failed · {remaining_tasks} remaining."
2301
+ )
2302
+ else:
2303
+ headline = (
2304
+ f"Execution finished with issues · {failed_tasks} failed · "
2305
+ f"{remaining_tasks} remaining."
2306
+ )
2307
+ done_ok = total_tasks > 0 and failed_tasks == 0 and remaining_tasks == 0 and code == 0
2308
+ console.print(
2309
+ _forge_phase_rule(console=console, label="DONE"),
2310
+ highlight=False,
2311
+ )
2312
+ dot = "●" if _forge_supports_unicode_glyphs(console) else "*"
2313
+ console.print(
2314
+ Text.assemble(
2315
+ ("│ ", STYLE_CHROME),
2316
+ (f"{dot} ", STYLE_SUCCESS if done_ok else STYLE_WARN),
2317
+ (headline, STYLE_EMPHASIS),
2318
+ ),
2319
+ highlight=False,
2320
+ )
2321
+ _print_forge_meta(
2322
+ console=console,
2323
+ message=(
2324
+ f"Tasks · {total_tasks} total · {done_tasks} done · "
2325
+ f"{failed_tasks} failed · {remaining_tasks} remaining"
2326
+ ),
2327
+ )
2328
+ _print_forge_meta(console=console, message=f"Summary · {summary_path}")
2329
+
2330
+ # The run's actual answer: what each task did, where the files
2331
+ # landed, whether the output is verified, and how to try it. Built
2332
+ # deterministically from run artifacts (no model call). The TUI
2333
+ # passes a sink so this renders as a real assistant block instead
2334
+ # of joining the dim captured console text; classic chat prints it
2335
+ # inline as markdown.
2336
+ completion_report = ""
2337
+ try:
2338
+ completion_report = build_forge_completion_report(
2339
+ paths=paths,
2340
+ plan=plan_after_execution,
2341
+ run_status=run_status,
2342
+ run_clean=run_clean,
2343
+ exit_code=code,
2344
+ )
2345
+ except Exception: # noqa: BLE001
2346
+ completion_report = ""
2347
+ if completion_report:
2348
+ try:
2349
+ (paths.execution_dir / "completion_report.md").write_text(
2350
+ completion_report + "\n", encoding="utf-8"
2351
+ )
2352
+ except Exception: # noqa: BLE001
2353
+ pass
2354
+ if forge_execution_report_sink is not None:
2355
+ try:
2356
+ forge_execution_report_sink(completion_report)
2357
+ except Exception: # noqa: BLE001
2358
+ pass
2359
+ else:
2360
+ console.print()
2361
+ console.print(Markdown(completion_report), highlight=False)
2362
+
2363
+ # The run just rewrote the workspace; drop the cached pre-execution
2364
+ # scans so follow-up planner questions ("how do I run it?") are
2365
+ # answered against what now actually exists on disk.
2366
+ forge_state.workspace_context = None
2367
+ if getattr(session, "planner_workspace_context", None) is not None:
2368
+ try:
2369
+ session.planner_workspace_context = None
2370
+ except Exception: # noqa: BLE001
2371
+ pass
2372
+
2373
+ append_transcript_note(
2374
+ paths,
2375
+ role="system",
2376
+ message=(
2377
+ "Forge /execute plan completed. "
2378
+ f"Tasks: total={total_tasks}, done={done_tasks}, "
2379
+ f"failed={failed_tasks}, remaining={remaining_tasks}."
2380
+ ),
2381
+ )
2382
+ return "handled"
2383
+ finally:
2384
+ run_mutation_guard.release()
2385
+
2386
+ if cmd in {"/assistant"}:
2387
+ assistant_cmd = arg.lower()
2388
+ if not assistant_cmd:
2389
+ assistant_cmd, picker_available = _select_forge_assistant_interactive(
2390
+ enabled=forge_state.assistant_enabled,
2391
+ console=console,
2392
+ )
2393
+ if not picker_available:
2394
+ append_transcript_note(paths, role="user", message=trimmed)
2395
+ append_transcript_note(
2396
+ paths,
2397
+ role="system",
2398
+ message="Rejected invalid /assistant usage.",
2399
+ )
2400
+ _print_forge_warning_messages(
2401
+ console=console,
2402
+ label="Usage",
2403
+ warnings=["/assistant on|off|status"],
2404
+ )
2405
+ return "handled"
2406
+ if assistant_cmd is None:
2407
+ return "handled"
2408
+ if assistant_cmd == "on":
2409
+ forge_state.assistant_enabled = True
2410
+ append_transcript_note(paths, role="user", message=trimmed)
2411
+ append_transcript_note(paths, role="system", message="Planner assistant enabled.")
2412
+ _print_forge_meta(console=console, message="Planner assistant: ON")
2413
+ return "handled"
2414
+ if assistant_cmd == "off":
2415
+ forge_state.assistant_enabled = False
2416
+ forge_state.planner_awaiting_clarification = False
2417
+ forge_state.planner_pending_questions = []
2418
+ append_transcript_note(paths, role="user", message=trimmed)
2419
+ append_transcript_note(paths, role="system", message="Planner assistant disabled.")
2420
+ _print_forge_meta(console=console, message="Planner assistant: OFF")
2421
+ return "handled"
2422
+ if assistant_cmd == "status":
2423
+ state = "ON" if forge_state.assistant_enabled else "OFF"
2424
+ append_transcript_note(paths, role="user", message=trimmed)
2425
+ append_transcript_note(
2426
+ paths,
2427
+ role="system",
2428
+ message=f"Planner assistant status requested ({state}).",
2429
+ )
2430
+ _print_forge_meta(console=console, message=f"Planner assistant: {state}")
2431
+ return "handled"
2432
+ append_transcript_note(paths, role="user", message=trimmed)
2433
+ append_transcript_note(paths, role="system", message="Rejected invalid /assistant usage.")
2434
+ _print_forge_warning_messages(
2435
+ console=console,
2436
+ label="Usage",
2437
+ warnings=["/assistant on|off|status"],
2438
+ )
2439
+ return "handled"
2440
+
2441
+ if cmd == "/goal":
2442
+ append_transcript_note(paths, role="user", message=trimmed)
2443
+ if not arg:
2444
+ append_transcript_note(paths, role="system", message="Rejected empty /goal.")
2445
+ _print_forge_warning_messages(
2446
+ console=console,
2447
+ label="Usage",
2448
+ warnings=["/goal <text>"],
2449
+ )
2450
+ return "handled"
2451
+ goal = arg
2452
+ plan["project_goal"] = goal
2453
+ if not str(plan.get("summary") or "").strip():
2454
+ plan["summary"] = goal
2455
+ save_plan(paths, plan)
2456
+ append_transcript_note(paths, role="system", message="Updated project goal.")
2457
+ _print_forge_meta(console=console, message="Project goal updated.")
2458
+ return "handled"
2459
+
2460
+ if cmd == "/task":
2461
+ append_transcript_note(paths, role="user", message=trimmed)
2462
+ if not arg:
2463
+ append_transcript_note(paths, role="system", message="Rejected empty /task.")
2464
+ _print_forge_warning_messages(
2465
+ console=console,
2466
+ label="Usage",
2467
+ warnings=["/task <title>"],
2468
+ )
2469
+ return "handled"
2470
+ title = arg
2471
+ try:
2472
+ task = add_task(
2473
+ plan,
2474
+ title=title,
2475
+ description=f"Manual planning chat task: {title}",
2476
+ )
2477
+ except ForgeError as e:
2478
+ append_transcript_note(
2479
+ paths,
2480
+ role="system",
2481
+ message=f"Rejected /task because it lacked runnable file scope: {e}",
2482
+ )
2483
+ _print_forge_warning_messages(
2484
+ console=console,
2485
+ label="Task rejected",
2486
+ warnings=[str(e)],
2487
+ )
2488
+ return "handled"
2489
+ save_plan(paths, plan)
2490
+ append_transcript_note(paths, role="system", message=f"Added task {task['id']}.")
2491
+ _print_forge_meta(
2492
+ console=console,
2493
+ message=f"Added task {task['id']} · {task['title']}",
2494
+ )
2495
+ return "handled"
2496
+
2497
+ if cmd[:1] in "/:":
2498
+ return "unhandled"
2499
+
2500
+ # In the TUI the user line is already echoed (and the reply is routed to the
2501
+ # assistant-markdown renderer via the sink), so skip the captured "You:" label
2502
+ # that would otherwise double the echo as flat text.
2503
+ if forge_planner_reply_sink is None:
2504
+ _render_labeled_chat_message(console=console, label="You", message=trimmed)
2505
+
2506
+ append_transcript_note(paths, role="user", message=trimmed)
2507
+
2508
+ if forge_state.assistant_enabled:
2509
+ planning_relevant = True
2510
+ session_cfg = getattr(session, "cfg", None)
2511
+ planner_api_key = str(
2512
+ getattr(getattr(session, "client", None), "api_key", "") or ""
2513
+ ).strip()
2514
+ api_key_override = planner_api_key or None
2515
+ session_stream = bool(getattr(session, "stream", False))
2516
+ # With a reply sink (TUI) the reply is rendered as a single assistant block
2517
+ # on completion, so suppress the token-delta trace that would otherwise
2518
+ # spill the streaming text as separate trace lines.
2519
+ on_text_delta = (
2520
+ _make_plan_mode_delta_trace_callback(session=session)
2521
+ if (session_stream and forge_planner_reply_sink is None)
2522
+ else None
2523
+ )
2524
+ stream_planner = session_stream and on_text_delta is not None
2525
+ _emit_forge_planner_trace(
2526
+ session=session,
2527
+ message="Planner assistant is analyzing your request.",
2528
+ )
2529
+ if stream_planner:
2530
+ _emit_forge_planner_trace(
2531
+ session=session,
2532
+ message="Streaming planner response...",
2533
+ full_only=True,
2534
+ )
2535
+ _run_forge_planner_turn_controller(
2536
+ console=console,
2537
+ paths=paths,
2538
+ plan=plan,
2539
+ planner_state=forge_state.planner_session,
2540
+ user_text=trimmed,
2541
+ cfg_loader=(
2542
+ lambda: (
2543
+ clone_cfg(session_cfg)
2544
+ if isinstance(session_cfg, AppConfig)
2545
+ else (_ for _ in ()).throw(ConfigError("session config missing"))
2546
+ )
2547
+ ),
2548
+ unavailable_message_builder=lambda _error: (
2549
+ "Planner assistant is unavailable because session config is missing."
2550
+ ),
2551
+ emit_meta=lambda message: _print_forge_meta(console=console, message=message),
2552
+ emit_warning_group=lambda label, warnings: _print_forge_warning_messages(
2553
+ console=console,
2554
+ label=label,
2555
+ warnings=warnings,
2556
+ ),
2557
+ api_key_override=api_key_override,
2558
+ render_reply=(
2559
+ forge_planner_reply_sink
2560
+ if forge_planner_reply_sink is not None
2561
+ else (
2562
+ lambda message, questions: _render_planner_reply(
2563
+ console=console,
2564
+ message=message,
2565
+ questions=questions,
2566
+ )
2567
+ )
2568
+ ),
2569
+ selection_label="planner",
2570
+ planning_relevant=planning_relevant,
2571
+ refresh_workspace_context=(
2572
+ forge_state.workspace_context is None
2573
+ and hasattr(paths, "root")
2574
+ and hasattr(paths, "workspace_context_json_path")
2575
+ ),
2576
+ trace_callback=lambda message, full_only=False: _emit_forge_planner_trace(
2577
+ session=session,
2578
+ message=message,
2579
+ full_only=full_only,
2580
+ ),
2581
+ stream=stream_planner,
2582
+ on_text_delta=on_text_delta,
2583
+ on_usage_events=lambda payloads: _merge_usage_payloads_into_session(
2584
+ session=session,
2585
+ payloads=payloads,
2586
+ ),
2587
+ error_fallback=lambda: _capture_forge_requirement_from_planner_fallback(
2588
+ plan=plan,
2589
+ paths=paths,
2590
+ console=console,
2591
+ user_text=trimmed,
2592
+ planning_relevant=planning_relevant,
2593
+ ),
2594
+ )
2595
+ return "handled"
2596
+
2597
+ goal_was_set = _set_forge_project_goal_if_empty(
2598
+ plan=plan,
2599
+ paths=paths,
2600
+ console=console,
2601
+ goal=trimmed,
2602
+ )
2603
+ if goal_was_set:
2604
+ add_requirement(plan, trimmed)
2605
+ save_plan(paths, plan)
2606
+ append_transcript_note(
2607
+ paths,
2608
+ role="system",
2609
+ message="Captured project goal as planning input.",
2610
+ )
2611
+ else:
2612
+ add_requirement(plan, trimmed)
2613
+ save_plan(paths, plan)
2614
+ append_transcript_note(paths, role="system", message="Captured requirement note.")
2615
+ _print_forge_meta(console=console, message="Captured requirement note.")
2616
+ return "handled"