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,4508 @@
1
+ # ruff: noqa: F821
2
+ # Dependencies are injected at runtime from alysis_code.cli to preserve monkeypatch surfaces.
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+
13
+ from ...branding import env_get
14
+ from ...compaction.conversation_compactor import CompactionState
15
+ from ...error_text import sanitize_error_text_for_output
16
+ from ...failure_category import exit_code_for_failure
17
+ from ...personas import (
18
+ DEFAULT_PERSONA,
19
+ get_persona,
20
+ is_persona_name,
21
+ next_persona,
22
+ normalize_persona,
23
+ persona_modes_enabled,
24
+ persona_overlay_messages,
25
+ persona_overlay_user_messages,
26
+ resolve_persona_exec_mode,
27
+ )
28
+ from ...run_outcome import INFRASTRUCTURE_FAILURE_EXIT_CODE
29
+ from ...runtime_kind import RuntimeKind
30
+ from ...surface.console import safe_plain_error
31
+ from .state import _ChatExecutionRequest, _ChatPlanModeState, _ForgeChatState
32
+
33
+ _PROTECTED_GLOBAL_NAMES: set[str] = set()
34
+ _PATCHABLE_DEPENDENCY_GLOBAL_NAMES: set[str] = {
35
+ "create_session",
36
+ "load_config",
37
+ "_chat_trace_level",
38
+ "_current_branch_label",
39
+ "_guarded_workspace_prompt_text",
40
+ "_is_git_dirty",
41
+ "_is_narrow_terminal",
42
+ "_is_non_interactive_terminal",
43
+ "_maybe_make_chat_prompt_session",
44
+ "_run_inline_option_selector",
45
+ "_select_guarded_workspace_action_interactive",
46
+ "paste_clipboard_image",
47
+ "typer",
48
+ }
49
+
50
+
51
+ def _sync_cli_globals(cli_mod: Any) -> None:
52
+ module_globals = globals()
53
+ if not _PROTECTED_GLOBAL_NAMES:
54
+ for local_name, local_value in module_globals.items():
55
+ if callable(local_value):
56
+ _PROTECTED_GLOBAL_NAMES.add(local_name)
57
+ for name, value in cli_mod.__dict__.items():
58
+ if name.startswith("__"):
59
+ continue
60
+ if name in _PROTECTED_GLOBAL_NAMES and name not in _PATCHABLE_DEPENDENCY_GLOBAL_NAMES:
61
+ continue
62
+ if (
63
+ name in _PROTECTED_GLOBAL_NAMES
64
+ and module_globals.get(name) is value
65
+ and name in _PATCHABLE_DEPENDENCY_GLOBAL_NAMES
66
+ ):
67
+ continue
68
+ module_globals[name] = value
69
+
70
+
71
+ def _path_binding_source(path_source: Any, path: Any) -> str:
72
+ if path_source is not None and path_source is not ParameterSource.DEFAULT:
73
+ return "explicit_path"
74
+ if path not in (None, ".", Path(".")):
75
+ return "explicit_path"
76
+ return "cwd"
77
+
78
+
79
+ _RAW_BENCHMARK_PROFILE_NAMES: set[str] = {
80
+ "benchmark",
81
+ "bench",
82
+ "raw-agent",
83
+ "raw_agent",
84
+ "raw-benchmark",
85
+ "raw_benchmark",
86
+ "raw-agent-benchmark",
87
+ "raw_agent_benchmark",
88
+ }
89
+ _RAW_BENCHMARK_MIN_MAX_STEPS = 80
90
+
91
+
92
+ def _raw_benchmark_profile_requested(*, benchmark: bool) -> bool:
93
+ if benchmark:
94
+ return True
95
+ profile = env_get("ALYSIS_RUN_PROFILE", "") or ""
96
+ normalized = profile.strip().lower()
97
+ return normalized in _RAW_BENCHMARK_PROFILE_NAMES
98
+
99
+
100
+ def _apply_raw_benchmark_profile(cfg: Any) -> None:
101
+ cfg.default_mode = "auto"
102
+ cfg.routing_mode = "code_only"
103
+ cfg.subagents_enabled = False
104
+ cfg.skills_enabled = False
105
+ cfg.skills_auto_invoke = False
106
+ cfg.custom_tools_enabled = False
107
+ cfg.web_search_mode = "off"
108
+ cfg.max_steps = max(int(getattr(cfg, "max_steps", 0) or 0), _RAW_BENCHMARK_MIN_MAX_STEPS)
109
+
110
+
111
+ _CHAT_WORKDIR_NAVIGATION_PREFIXES: tuple[str, ...] = (
112
+ "from now on operate under ",
113
+ "from now on operate in ",
114
+ "from now on work under ",
115
+ "from now on work in ",
116
+ "switch to ",
117
+ "work under ",
118
+ "work in ",
119
+ "operate under ",
120
+ "operate in ",
121
+ "go to ",
122
+ )
123
+ _CHAT_WORKDIR_TARGET_RE = re.compile(
124
+ r"^(?P<path>\"[^\"]+\"|'[^']+'|[^,\s]+(?:[\\/][^,\s]+)*)(?:\s*(?:,?\s*(?:and|then)\s+)(?P<rest>.+))?$",
125
+ re.IGNORECASE,
126
+ )
127
+ _CHAT_WORKDIR_FALSE_POSITIVE_TARGETS = {"definition"}
128
+ _STRICT_SHELL_SANDBOX_UNAVAILABLE_PREFIX = (
129
+ "Shell sandbox strict mode is enabled, but no usable backend is available:"
130
+ )
131
+
132
+
133
+ def _strip_rich_markup(text: Any) -> str:
134
+ """Best-effort strip of Rich console markup (``[red]…[/red]``) for plain
135
+ transcript display. The resume status strings carry colour tags meant for the
136
+ classic Rich console; in the TUI they render literally, so flatten them."""
137
+ raw = str(text or "")
138
+ try:
139
+ from rich.text import Text
140
+
141
+ return Text.from_markup(raw).plain
142
+ except Exception: # noqa: BLE001 - malformed markup → fall back to a regex
143
+ return re.sub(r"\[/?[a-zA-Z][^\]]*\]", "", raw)
144
+
145
+
146
+ def _print_chat_missing_model_guidance(console: Any) -> None:
147
+ console.print("[yellow]No model is configured yet.[/yellow]")
148
+ console.print(
149
+ "Run `alysis setup` for guided setup, or `alysis login` to connect your Alysis Code account."
150
+ )
151
+ console.print("You can also set one directly with `alysis config set model <MODEL>`.")
152
+
153
+
154
+ def _sync_tui_session_state(
155
+ tui_state: Any, session: Any, *, include_exec_mode: bool = False
156
+ ) -> None:
157
+ if include_exec_mode:
158
+ tui_state.exec_mode = str(getattr(session, "mode", "") or "").strip()
159
+ # A persona model swap changes the live client, not cfg.model — the
160
+ # footer should show what will actually answer.
161
+ live_model = str(getattr(getattr(session, "client", None), "model", "") or "")
162
+ if live_model:
163
+ tui_state.model_name = live_model
164
+ # Empty when the feature is off so the footer renders no persona half.
165
+ tui_state.persona = (
166
+ normalize_persona(getattr(session, "persona", "code"), _session_persona_registry(session))
167
+ if persona_modes_enabled(getattr(session, "cfg", None))
168
+ else ""
169
+ )
170
+ try:
171
+ usage_enabled = globals().get("_chat_usage_hud_enabled")
172
+ if callable(usage_enabled):
173
+ tui_state.usage_hud_enabled = bool(usage_enabled(session))
174
+ else:
175
+ tui_state.usage_hud_enabled = bool(getattr(session, "_usage_hud_enabled", True))
176
+ except Exception:
177
+ pass
178
+
179
+
180
+ def _is_default_shell_sandbox_startup_failure(*, cfg: Any, error: Exception) -> bool:
181
+ if not str(error).startswith(_STRICT_SHELL_SANDBOX_UNAVAILABLE_PREFIX):
182
+ return False
183
+ if env_get("ALYSIS_SHELL_SANDBOX_MODE") is not None:
184
+ return False
185
+ extra_fields = getattr(cfg, "extra_fields", {})
186
+ if not isinstance(extra_fields, dict):
187
+ return True
188
+ shell_cfg = extra_fields.get("shell_sandbox")
189
+ if not isinstance(shell_cfg, dict):
190
+ return True
191
+ return shell_cfg.get("mode") is None
192
+
193
+
194
+ def _cfg_with_warn_shell_sandbox_mode(cfg: Any) -> Any:
195
+ extra_fields = dict(getattr(cfg, "extra_fields", {}) or {})
196
+ shell_cfg = dict(extra_fields.get("shell_sandbox") or {})
197
+ shell_cfg["mode"] = "warn"
198
+ extra_fields["shell_sandbox"] = shell_cfg
199
+ return cfg.model_copy(update={"extra_fields": extra_fields}, deep=True)
200
+
201
+
202
+ def _resolve_forge_entry_root(*, session: Any, fallback_root: Path) -> Path:
203
+ session_root = getattr(session, "root", None)
204
+ if session_root is None:
205
+ return Path(fallback_root).resolve()
206
+ return Path(resolve_session_active_workdir_path(session)).resolve()
207
+
208
+
209
+ def _chat_plan_usage_lines() -> tuple[str, ...]:
210
+ return (
211
+ "[yellow]Usage:[/yellow] /plan <task> default draft/review/approve flow; can execute after approval",
212
+ " /plan mode secondary persistent readonly planning overlay",
213
+ " /plan approve only inside Plan Mode; executes the stored draft",
214
+ " /plan off",
215
+ " /plan status",
216
+ "[dim]Compatibility:[/dim] /plan draft <task>, /plan readonly, /plan on",
217
+ )
218
+
219
+
220
+ def _chat_plan_already_on_message(*, plan_mode_escape_supported: bool) -> str:
221
+ if plan_mode_escape_supported:
222
+ return "Plan Mode already on. Press Esc at an empty prompt or use /plan off to leave."
223
+ return "Plan Mode already on. Use /plan off to leave."
224
+
225
+
226
+ def _chat_plan_draft_blocked_by_mode_lines(*, plan_mode_escape_supported: bool) -> tuple[str, ...]:
227
+ lines = [
228
+ "Cannot start /plan while Plan Mode is on.",
229
+ "Use /plan off first, then use /plan <task> for the default draft/review/approve flow.",
230
+ ]
231
+ if plan_mode_escape_supported:
232
+ lines.append("Press Esc at an empty prompt to leave interactively.")
233
+ return tuple(lines)
234
+
235
+
236
+ def _chat_plan_readonly_mode_guidance_lines() -> tuple[str, ...]:
237
+ return (
238
+ "Cannot start /plan in Read-Only mode.",
239
+ "Switch to /mode review, /mode auto, or /mode fullaccess, then use /plan <task> for the default draft/review/approve flow.",
240
+ "Use /plan mode only when you explicitly want persistent readonly planning.",
241
+ )
242
+
243
+
244
+ _PLAN_MODE_EXECUTE_NOW_RE = re.compile(
245
+ r"^(?:(?:ok(?:ay)?|yes|yeah|yep|sure|please)\s+)?"
246
+ r"(?:do it|go ahead|go for it|implement(?: it)?|execute(?: it)?|"
247
+ r"start(?: implementing| coding)?|proceed|run it|apply it|ship it)"
248
+ r"(?:\s+(?:now|then|please))?$",
249
+ re.IGNORECASE,
250
+ )
251
+ _PLAN_MODE_NUMBERED_STEP_RE = re.compile(r"^\s{0,3}\d+[.)]\s+\S")
252
+ _PLAN_MODE_TASK_PREVIEW_CHARS = 96
253
+
254
+
255
+ def _chat_plan_mode_latest_task(plan_mode_state: Any) -> str | None:
256
+ task = getattr(plan_mode_state, "latest_task", None)
257
+ clean = str(task or "").strip()
258
+ return clean or None
259
+
260
+
261
+ def _chat_plan_mode_latest_draft(plan_mode_state: Any) -> str | None:
262
+ draft = getattr(plan_mode_state, "latest_draft", None)
263
+ clean = str(draft or "").strip()
264
+ return clean or None
265
+
266
+
267
+ def _clear_chat_plan_mode_draft_state(plan_mode_state: Any) -> None:
268
+ if hasattr(plan_mode_state, "latest_task"):
269
+ plan_mode_state.latest_task = None
270
+ if hasattr(plan_mode_state, "latest_draft"):
271
+ plan_mode_state.latest_draft = None
272
+
273
+
274
+ def _store_chat_plan_mode_draft_state(
275
+ plan_mode_state: Any,
276
+ *,
277
+ user_message: str,
278
+ draft: str,
279
+ ) -> bool:
280
+ task = str(user_message or "").strip()
281
+ clean_draft = str(draft or "").strip()
282
+ previous_task = _chat_plan_mode_latest_task(plan_mode_state)
283
+ previous_draft = _chat_plan_mode_latest_draft(plan_mode_state)
284
+ if hasattr(plan_mode_state, "latest_task"):
285
+ plan_mode_state.latest_task = task or None
286
+ if hasattr(plan_mode_state, "latest_draft"):
287
+ plan_mode_state.latest_draft = clean_draft or None
288
+ return previous_task != (task or None) or previous_draft != (clean_draft or None)
289
+
290
+
291
+ def _chat_plan_task_preview(task: str | None) -> str:
292
+ clean = re.sub(r"\s+", " ", str(task or "").strip())
293
+ if len(clean) <= _PLAN_MODE_TASK_PREVIEW_CHARS:
294
+ return clean
295
+ return clean[: _PLAN_MODE_TASK_PREVIEW_CHARS - 3].rstrip() + "..."
296
+
297
+
298
+ def _looks_like_actionable_plan_mode_draft(text: str) -> bool:
299
+ clean = str(text or "").strip()
300
+ if not clean:
301
+ return False
302
+ steps = sum(1 for line in clean.splitlines() if _PLAN_MODE_NUMBERED_STEP_RE.match(line.strip()))
303
+ return steps >= 2
304
+
305
+
306
+ def _latest_assistant_text_since(session: Any, *, start_index: int = 0) -> str | None:
307
+ messages = getattr(session, "messages", None)
308
+ if not isinstance(messages, list):
309
+ return None
310
+ start = max(int(start_index or 0), 0)
311
+ for entry in reversed(messages[start:]):
312
+ if not isinstance(entry, dict):
313
+ continue
314
+ role = str(entry.get("role") or "").strip().lower()
315
+ if role != "assistant":
316
+ continue
317
+ text = str(entry.get("content") or "").strip()
318
+ if text:
319
+ return text
320
+ return None
321
+
322
+
323
+ def _plan_mode_no_stored_draft_lines() -> tuple[str, ...]:
324
+ return (
325
+ "No stored actionable Plan Mode draft is available yet.",
326
+ "Send the concrete implementation request as a normal chat message first.",
327
+ "/plan <task> remains the default draft/review/approve path outside Plan Mode.",
328
+ "Once the host captures a numbered draft, use exact /plan approve to execute it or /plan off to leave.",
329
+ )
330
+
331
+
332
+ def _plan_mode_readonly_origin_lines() -> tuple[str, ...]:
333
+ return (
334
+ "This Plan Mode overlay was entered from plain Read-Only mode.",
335
+ "Exact /plan approve cannot execute into a readonly session.",
336
+ "Use /plan off to leave the overlay, then switch to /mode review, /mode auto, or /mode fullaccess and use /plan <task> for execution.",
337
+ )
338
+
339
+
340
+ def _plan_mode_entry_guidance_lines(
341
+ *,
342
+ restore_mode: str,
343
+ ) -> tuple[str, ...]:
344
+ if restore_mode == "readonly":
345
+ return (
346
+ "Plan Mode is a persistent readonly planning overlay. It does not execute by itself.",
347
+ "If you want readonly planning here, send the concrete implementation task as a normal chat message.",
348
+ "For the default draft/review/approve flow, use /plan off, switch to /mode review, /mode auto, or /mode fullaccess, then use /plan <task>.",
349
+ )
350
+ return (
351
+ "Plan Mode is a persistent readonly planning overlay. It does not execute by itself.",
352
+ "For the default draft/review/approve flow, leave with /plan off and use /plan <task>.",
353
+ "If you want readonly planning here, send the concrete implementation task as a normal chat message.",
354
+ f"When the latest draft looks right, use exact /plan approve to leave Plan Mode, restore {_chat_mode_display(restore_mode)}, and execute it.",
355
+ "Use /plan off to leave without execution.",
356
+ )
357
+
358
+
359
+ def _plan_mode_execute_now_guidance_lines(
360
+ *,
361
+ plan_mode_state: Any,
362
+ plan_mode_escape_supported: bool,
363
+ ) -> tuple[str, ...]:
364
+ restore_mode = _chat_plan_mode_restore_mode(plan_mode_state) or "readonly"
365
+ latest_draft = _chat_plan_mode_latest_draft(plan_mode_state)
366
+ lines = [
367
+ "Plan Mode is still on and stays read-only.",
368
+ "/plan <task> remains the default draft/review/approve path outside Plan Mode.",
369
+ ]
370
+ if latest_draft is None:
371
+ lines.extend(_plan_mode_no_stored_draft_lines())
372
+ elif restore_mode == "readonly":
373
+ lines.append("A latest actionable draft is already stored for this session.")
374
+ lines.extend(_plan_mode_readonly_origin_lines())
375
+ else:
376
+ lines.append("A latest actionable draft is already stored for this session.")
377
+ lines.append(
378
+ f"Use exact /plan approve to leave Plan Mode, restore {_chat_mode_display(restore_mode)}, and execute that draft."
379
+ )
380
+ lines.append("Use /plan off to leave without execution.")
381
+ if plan_mode_escape_supported:
382
+ lines.append("Press Esc at an empty prompt to leave interactively.")
383
+ return tuple(lines)
384
+
385
+
386
+ def _is_plan_mode_execute_now_follow_up(user_message: str) -> bool:
387
+ normalized = re.sub(r"\s+", " ", str(user_message or "").strip())
388
+ if not normalized:
389
+ return False
390
+ trimmed = normalized.strip(" \t\r\n.,;:!?")
391
+ return bool(_PLAN_MODE_EXECUTE_NOW_RE.fullmatch(trimmed))
392
+
393
+
394
+ def _parse_chat_plan_command(raw_plan_arg: str) -> tuple[str, str]:
395
+ clean = str(raw_plan_arg or "").strip()
396
+ if not clean:
397
+ return ("draft", "")
398
+ lowered = clean.lower()
399
+ if lowered in {"mode", "readonly", "on", "approve", "off", "status"}:
400
+ return (lowered, "")
401
+ if lowered == "draft":
402
+ return ("draft", "")
403
+ if lowered.startswith("draft "):
404
+ return ("draft", clean[6:].strip())
405
+ return ("draft", clean)
406
+
407
+
408
+ def _strip_wrapping_quotes(text: str) -> str:
409
+ stripped = str(text or "").strip()
410
+ if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in {'"', "'"}:
411
+ return stripped[1:-1].strip()
412
+ return stripped
413
+
414
+
415
+ def _classify_chat_workdir_target(*, session: Any, raw_path: str) -> tuple[bool, str | None]:
416
+ requested_path = str(raw_path or "").strip()
417
+ if not requested_path:
418
+ return (False, None)
419
+ lowered = requested_path.casefold()
420
+ if lowered in _CHAT_WORKDIR_FALSE_POSITIVE_TARGETS:
421
+ return (False, None)
422
+ pathlike = (
423
+ "/" in requested_path
424
+ or "\\" in requested_path
425
+ or requested_path.startswith(".")
426
+ or Path(requested_path).is_absolute()
427
+ )
428
+ workspace_root = Path(getattr(session, "root", Path("."))).resolve()
429
+ current_workdir = Path(resolve_session_active_workdir_path(session))
430
+ requested_obj = Path(requested_path)
431
+ candidate = (
432
+ requested_obj.resolve()
433
+ if requested_obj.is_absolute()
434
+ else (current_workdir / requested_obj).resolve()
435
+ )
436
+ try:
437
+ candidate.relative_to(workspace_root)
438
+ except ValueError:
439
+ if pathlike:
440
+ return (
441
+ True,
442
+ "Requested path escapes the bound workspace_root. Start a new session for another workspace.",
443
+ )
444
+ return (False, None)
445
+ if not candidate.exists():
446
+ if pathlike:
447
+ return (True, f"Directory does not exist: {candidate}")
448
+ return (False, None)
449
+ if not candidate.is_dir():
450
+ if pathlike:
451
+ return (True, f"Path is not a directory: {candidate}")
452
+ return (False, None)
453
+ return (True, None)
454
+
455
+
456
+ def _parse_chat_workdir_navigation_request(
457
+ *,
458
+ input_text: str,
459
+ session: Any,
460
+ ) -> tuple[str, str] | tuple[str, str, str] | None:
461
+ trimmed = str(input_text or "").strip()
462
+ lowered = trimmed.casefold()
463
+ prefix = next(
464
+ (item for item in _CHAT_WORKDIR_NAVIGATION_PREFIXES if lowered.startswith(item)), None
465
+ )
466
+ if prefix is None:
467
+ return None
468
+ remainder = trimmed[len(prefix) :].strip()
469
+ if not remainder:
470
+ return None
471
+ match = _CHAT_WORKDIR_TARGET_RE.match(remainder)
472
+ if match is None:
473
+ return None
474
+ raw_requested_path = _strip_wrapping_quotes(match.group("path") or "")
475
+ requested_path = (
476
+ raw_requested_path
477
+ if raw_requested_path in {".", ".."}
478
+ else raw_requested_path.rstrip(".,;:!?")
479
+ )
480
+ if not requested_path:
481
+ return None
482
+ should_handle, error_message = _classify_chat_workdir_target(
483
+ session=session,
484
+ raw_path=requested_path,
485
+ )
486
+ if not should_handle:
487
+ return None
488
+ trailing_instruction = str(match.group("rest") or "").strip()
489
+ if error_message:
490
+ return (requested_path, trailing_instruction, error_message)
491
+ return (requested_path, trailing_instruction)
492
+
493
+
494
+ def _forge_plan_command_guidance_lines(*args: Any, **kwargs: Any) -> Any:
495
+ from . import rendering as _rendering
496
+
497
+ _rendering._sync_rendering_globals(globals())
498
+ return _rendering._forge_plan_command_guidance_lines(*args, **kwargs)
499
+
500
+
501
+ def _print_forge_plan_command_guidance(*args: Any, **kwargs: Any) -> Any:
502
+ from . import rendering as _rendering
503
+
504
+ _rendering._sync_rendering_globals(globals())
505
+ return _rendering._print_forge_plan_command_guidance(*args, **kwargs)
506
+
507
+
508
+ def _open_assets_modal(*, session: Any, console: Console, run_paths: Any) -> None:
509
+ cfg = getattr(session, "cfg", None)
510
+ if not isinstance(cfg, AppConfig):
511
+ cfg = load_config()
512
+ try:
513
+ from ..assets_modal import run_assets_modal
514
+
515
+ run_assets_modal(cfg=cfg, run_paths=run_paths, console=console)
516
+ except Exception as exc: # noqa: BLE001
517
+ console.print(f"[red]Assets modal failed:[/red] {exc}")
518
+
519
+
520
+ def _chat_skill_usage_lines(*args: Any, **kwargs: Any) -> Any:
521
+ from . import rendering as _rendering
522
+
523
+ _rendering._sync_rendering_globals(globals())
524
+ return _rendering._chat_skill_usage_lines(*args, **kwargs)
525
+
526
+
527
+ def _artifact_display_ref(*args: Any, **kwargs: Any) -> Any:
528
+ from . import rendering as _rendering
529
+
530
+ _rendering._sync_rendering_globals(globals())
531
+ return _rendering._artifact_display_ref(*args, **kwargs)
532
+
533
+
534
+ def _render_planner_reply(*args: Any, **kwargs: Any) -> Any:
535
+ from . import rendering as _rendering
536
+
537
+ _rendering._sync_rendering_globals(globals())
538
+ return _rendering._render_planner_reply(*args, **kwargs)
539
+
540
+
541
+ def _render_labeled_chat_message(*args: Any, **kwargs: Any) -> Any:
542
+ from . import rendering as _rendering
543
+
544
+ _rendering._sync_rendering_globals(globals())
545
+ return _rendering._render_labeled_chat_message(*args, **kwargs)
546
+
547
+
548
+ def _render_plan_draft(*args: Any, **kwargs: Any) -> Any:
549
+ from . import rendering as _rendering
550
+
551
+ _rendering._sync_rendering_globals(globals())
552
+ return _rendering._render_plan_draft(*args, **kwargs)
553
+
554
+
555
+ def _chat_plan_mode_enabled(plan_mode_state: Any) -> bool:
556
+ return bool(getattr(plan_mode_state, "enabled", False))
557
+
558
+
559
+ def _chat_plan_mode_restore_mode(plan_mode_state: Any) -> str | None:
560
+ restore_mode = getattr(plan_mode_state, "restore_mode", None)
561
+ if restore_mode is None:
562
+ return None
563
+ normalized = str(restore_mode).strip().lower()
564
+ return normalized or None
565
+
566
+
567
+ def _render_chat_plan_mode_status(*args: Any, **kwargs: Any) -> Any:
568
+ from . import rendering as _rendering
569
+
570
+ _rendering._sync_rendering_globals(globals())
571
+ return _rendering._render_chat_plan_mode_status(*args, **kwargs)
572
+
573
+
574
+ def _disable_chat_plan_mode(
575
+ *,
576
+ session: Any,
577
+ console: Console,
578
+ plan_mode_state: Any,
579
+ clear_draft: bool = True,
580
+ ) -> str | None:
581
+ restore_mode = _chat_plan_mode_restore_mode(plan_mode_state) or "review"
582
+ current_mode = str(getattr(session, "mode", "review")).strip().lower() or "review"
583
+ previous_task = _chat_plan_mode_latest_task(plan_mode_state)
584
+ previous_draft = _chat_plan_mode_latest_draft(plan_mode_state)
585
+ plan_mode_state.enabled = False
586
+ plan_mode_state.restore_mode = None
587
+ if clear_draft:
588
+ _clear_chat_plan_mode_draft_state(plan_mode_state)
589
+ if current_mode != restore_mode:
590
+ try:
591
+ _apply_chat_effective_mode(
592
+ session=session,
593
+ next_mode=restore_mode,
594
+ persist_default_mode=False,
595
+ )
596
+ except Exception as e: # noqa: BLE001
597
+ plan_mode_state.enabled = True
598
+ plan_mode_state.restore_mode = restore_mode
599
+ if hasattr(plan_mode_state, "latest_task"):
600
+ plan_mode_state.latest_task = previous_task
601
+ if hasattr(plan_mode_state, "latest_draft"):
602
+ plan_mode_state.latest_draft = previous_draft
603
+ console.print(f"[red]Failed to disable Plan Mode:[/red] {e}")
604
+ return None
605
+ console.print(
606
+ f"Plan Mode set for this session: off (restored {_chat_mode_display(restore_mode)})"
607
+ )
608
+ return restore_mode
609
+
610
+
611
+ def _record_plan_mode_draft_from_turn(
612
+ *,
613
+ session: Any,
614
+ console: Console,
615
+ plan_mode_state: Any,
616
+ user_message: str,
617
+ start_index: int,
618
+ ) -> None:
619
+ latest_reply = _latest_assistant_text_since(session, start_index=start_index)
620
+ if latest_reply is None or not _looks_like_actionable_plan_mode_draft(latest_reply):
621
+ return
622
+ changed = _store_chat_plan_mode_draft_state(
623
+ plan_mode_state,
624
+ user_message=user_message,
625
+ draft=latest_reply,
626
+ )
627
+ if not changed:
628
+ return
629
+ restore_mode = _chat_plan_mode_restore_mode(plan_mode_state) or "readonly"
630
+ task_preview = _chat_plan_task_preview(user_message)
631
+ console.print(f"[dim]Stored latest Plan Mode draft for:[/dim] {task_preview}")
632
+ if restore_mode == "readonly":
633
+ console.print(
634
+ "[dim]This overlay started from Read-Only mode, so exact /plan approve cannot execute it here.[/dim]"
635
+ )
636
+ return
637
+ console.print(
638
+ f"[dim]Use exact /plan approve to leave Plan Mode, restore {_chat_mode_display(restore_mode)}, and execute it.[/dim]"
639
+ )
640
+
641
+
642
+ def _apply_chat_effective_mode(
643
+ *,
644
+ session: Any,
645
+ next_mode: str,
646
+ persist_default_mode: bool,
647
+ ) -> None:
648
+ _rebuild_session_tools_for_mode(session=session, mode=next_mode)
649
+ session.mode = next_mode
650
+ if persist_default_mode and hasattr(session, "cfg"):
651
+ session.cfg.default_mode = next_mode
652
+ refresh_session_environment_context_message(session)
653
+ surface = getattr(session, "surface", None)
654
+ emit_mode_changed = getattr(surface, "emit_mode_changed", None)
655
+ if callable(emit_mode_changed):
656
+ emit_mode_changed(next_mode)
657
+
658
+
659
+ def _apply_chat_persona(
660
+ *,
661
+ session: Any,
662
+ persona: str,
663
+ source: str = "user",
664
+ ) -> str:
665
+ """Apply a persona to the live session; returns the effective exec mode.
666
+
667
+ The persona is a convention: it selects a default execution mode through
668
+ the clamp rule (lower freely, never raise above the user's chosen mode)
669
+ and everything enforcement-related still flows through
670
+ ``_apply_chat_effective_mode`` -> ``build_tools`` guards. Personas whose
671
+ default keeps the user's mode (code/debug) restore any mode narrowed by a
672
+ previous persona (architect/ask).
673
+ """
674
+ from ...agent.prompt_context import refresh_session_environment_context_message
675
+
676
+ definition = get_persona(persona, _session_persona_registry(session))
677
+ current_mode = str(getattr(session, "mode", "review") or "review").strip().lower()
678
+ restore = getattr(session, "persona_restore_mode", None)
679
+ restore_active = restore is not None
680
+ base_mode = str(restore or current_mode).strip().lower()
681
+ base_globs = (
682
+ getattr(session, "persona_restore_write_globs", None)
683
+ if restore_active
684
+ else getattr(session, "allow_write_globs", None)
685
+ )
686
+ target_mode = resolve_persona_exec_mode(definition, base_mode)
687
+ target_persona_globs = (
688
+ list(definition.allow_write_globs) if definition.allow_write_globs else None
689
+ )
690
+ swap = _resolve_persona_model_swap(session, definition)
691
+ prepared_client: Any | None = None
692
+ prepared_client_key: tuple[str, float] | None = None
693
+ if swap is not None:
694
+ swap_role, swap_model, swap_temperature = swap
695
+ try:
696
+ prepared_client, prepared_client_key = _prepare_session_client_for_persona(
697
+ session=session,
698
+ role=swap_role,
699
+ model=swap_model,
700
+ temperature=swap_temperature,
701
+ )
702
+ except Exception as exc:
703
+ store_obj = getattr(session, "store", None)
704
+ append_fail = getattr(store_obj, "append", None)
705
+ if callable(append_fail):
706
+ append_fail(
707
+ "persona_model_swap_failed",
708
+ {
709
+ "persona": definition.name,
710
+ "model": swap_model,
711
+ "error": sanitize_error_text_for_output(exc),
712
+ },
713
+ )
714
+ # A configured persona model is part of the switch contract. Do
715
+ # not silently land the persona on a different client, and do not
716
+ # mutate any persona state after strict-policy rejection.
717
+ raise
718
+ narrows = bool(definition.default_exec_mode) or bool(definition.allow_write_globs)
719
+ session.persona = definition.name
720
+ if narrows:
721
+ session.persona_restore_mode = base_mode
722
+ session.persona_restore_write_globs = list(base_globs) if base_globs is not None else None
723
+ else:
724
+ session.persona_restore_mode = None
725
+ session.persona_restore_write_globs = None
726
+ current_persona_globs = getattr(session, "persona_allow_write_globs", None)
727
+ scope_changed = (current_persona_globs or None) != (target_persona_globs or None)
728
+ # Preserve the user's base scope and give the gate both constraints. It
729
+ # evaluates them conjunctively instead of trying to synthesize glob
730
+ # intersections (which is incomplete for general glob languages).
731
+ session.allow_write_globs = list(base_globs) if base_globs is not None else None
732
+ session.persona_allow_write_globs = target_persona_globs
733
+ if prepared_client is not None and prepared_client_key is not None:
734
+ session.client = prepared_client
735
+ session.persona_client_key = prepared_client_key
736
+ if target_mode != current_mode or scope_changed:
737
+ # The rebuild inside _apply_chat_effective_mode re-reads the session's
738
+ # allow_write_globs, so a scope-only change still needs it.
739
+ _apply_chat_effective_mode(
740
+ session=session,
741
+ next_mode=target_mode,
742
+ persist_default_mode=False,
743
+ )
744
+ else:
745
+ refresh_session_environment_context_message(session)
746
+ surface = getattr(session, "surface", None)
747
+ emit_persona_changed = getattr(surface, "emit_persona_changed", None)
748
+ if callable(emit_persona_changed):
749
+ emit_persona_changed(definition.name, target_mode, source)
750
+ store = getattr(session, "store", None)
751
+ append = getattr(store, "append", None)
752
+ if callable(append):
753
+ append(
754
+ "persona_switch_applied",
755
+ {
756
+ "persona": definition.name,
757
+ "effective_mode": target_mode,
758
+ "source": source,
759
+ "model": str(getattr(getattr(session, "client", None), "model", "") or ""),
760
+ },
761
+ )
762
+ return target_mode
763
+
764
+
765
+ def _apply_startup_persona(*, session: Any, console: Any = None) -> None:
766
+ """Apply a non-default ``default_persona`` once at chat startup.
767
+
768
+ Session creation records ``session.persona`` from config but deliberately
769
+ leaves the execution mode alone (agent core stays persona-agnostic); the
770
+ chat loop owns applying the persona's default mode, exactly as it owns
771
+ every later transition.
772
+ """
773
+ cfg = getattr(session, "cfg", None)
774
+ if not persona_modes_enabled(cfg):
775
+ return
776
+ for warning in getattr(session, "persona_registry_warnings", ()) or ():
777
+ if console is not None:
778
+ console.print(f"[yellow]Custom persona:[/yellow] {warning}")
779
+ registry = _session_persona_registry(session)
780
+ configured = str(getattr(cfg, "default_persona", "code") or "code").strip().lower()
781
+ persona = normalize_persona(getattr(session, "persona", "code"), registry)
782
+ if (
783
+ console is not None
784
+ and configured != DEFAULT_PERSONA
785
+ and normalize_persona(configured, registry) == DEFAULT_PERSONA
786
+ ):
787
+ console.print(f"[yellow]Unknown persona {configured!r}; starting as code.[/yellow]")
788
+ if persona == "code" or getattr(session, "persona_restore_mode", None) is not None:
789
+ return
790
+ try:
791
+ _apply_chat_persona(session=session, persona=persona, source="config")
792
+ except Exception as exc: # noqa: BLE001
793
+ if console is not None:
794
+ console.print(f"[red]Failed to apply default persona {persona}:[/red] {exc}")
795
+ return
796
+ if console is not None:
797
+ console.print(f"[dim]Persona: {persona}[/dim]")
798
+
799
+
800
+ def _session_persona_registry(session: Any) -> Any:
801
+ return getattr(session, "persona_registry", None)
802
+
803
+
804
+ def _resolve_persona_model_swap(session: Any, definition: Any) -> tuple[str, str, float] | None:
805
+ """Resolved ``(role, model, temperature)`` when the client must change.
806
+
807
+ Resolution is entirely the existing chain (persona_models -> role ->
808
+ resolve_model_for_role precedence). With no role/persona model config the
809
+ chain lands on ``cfg.model`` for every persona, so default installs never
810
+ swap — sticky persona models activate only when the user configured them.
811
+ """
812
+ cfg = getattr(session, "cfg", None)
813
+ client_obj = getattr(session, "client", None)
814
+ if client_obj is None:
815
+ # No live client (bare test/session shims): nothing to swap.
816
+ return None
817
+ from ...config import resolve_role_temperature
818
+ from ...model_router import resolve_model_for_role
819
+ from ...personas import resolve_persona_model_role
820
+
821
+ role = resolve_persona_model_role(cfg, definition.name, _session_persona_registry(session))
822
+ target = str(resolve_model_for_role(cfg=cfg, role=role, plan=None) or "").strip()
823
+ temperature = float(resolve_role_temperature(cfg, role=role))
824
+ if not target:
825
+ return None
826
+ target_key = (target, temperature)
827
+ current_key = getattr(session, "persona_client_key", None)
828
+ if current_key is None:
829
+ current_model = str(getattr(client_obj, "model", "") or "").strip()
830
+ current_temperature = getattr(client_obj, "temperature", None)
831
+ try:
832
+ current_key = (current_model, float(current_temperature))
833
+ except (TypeError, ValueError):
834
+ # Bare test/session shims may not expose temperature. Preserve the
835
+ # historical no-op when the model itself is unchanged; real
836
+ # sessions always record persona_client_key.
837
+ if target == current_model:
838
+ return None
839
+ if target_key == current_key:
840
+ return None
841
+ return role, target, temperature
842
+
843
+
844
+ def _prepare_session_client_for_persona(
845
+ *, session: Any, role: str, model: str, temperature: float
846
+ ) -> tuple[Any, tuple[str, float]]:
847
+ """Validate and construct a persona client without changing live state.
848
+
849
+ Strict metadata policy applies to every model that can become active, not
850
+ only the model present at session creation. Clients are cached by the
851
+ resolved inputs that affect their behavior, so roles sharing a model but
852
+ using different temperatures cannot contaminate one another.
853
+ """
854
+ from ...agent.session import _make_session_llm_client
855
+ from ...config import (
856
+ resolve_llm_enable_thinking,
857
+ resolve_llm_reasoning_effort,
858
+ resolve_llm_timeout_s,
859
+ resolve_prompt_cache_key,
860
+ resolve_prompt_cache_retention,
861
+ )
862
+ from ...model_metadata_policy import (
863
+ ActiveModelRef,
864
+ evaluate_active_model_metadata_policy,
865
+ )
866
+ from ...model_registry import ModelRegistry
867
+
868
+ cfg = getattr(session, "cfg", None)
869
+ registry = getattr(session, "model_registry", None)
870
+ if registry is None:
871
+ registry = ModelRegistry(
872
+ cfg=cfg,
873
+ api_key=str(getattr(session, "api_key", "") or ""),
874
+ )
875
+ policy_result = evaluate_active_model_metadata_policy(
876
+ cfg=cfg,
877
+ registry=registry,
878
+ active_models=[ActiveModelRef(role=role, model_name=model)],
879
+ )
880
+ surface = getattr(session, "surface", None)
881
+ emit_warning = getattr(surface, "on_warning", None)
882
+ for warning_message in policy_result.warning_messages:
883
+ if callable(emit_warning):
884
+ emit_warning(warning_message)
885
+
886
+ cache = getattr(session, "persona_client_cache", None)
887
+ if cache is None:
888
+ cache = {}
889
+ session.persona_client_cache = cache
890
+ current_key = getattr(session, "persona_client_key", None)
891
+ current_client = getattr(session, "client", None)
892
+ if current_key is not None and current_client is not None:
893
+ cache.setdefault(current_key, current_client)
894
+ target_key = (model, temperature)
895
+ client = cache.get(target_key)
896
+ if client is None:
897
+ client = _make_session_llm_client(
898
+ cfg=cfg,
899
+ api_key=str(getattr(session, "api_key", "") or ""),
900
+ model=model,
901
+ timeout_s=resolve_llm_timeout_s(cfg),
902
+ temperature=temperature,
903
+ prompt_cache_key=getattr(
904
+ session,
905
+ "prompt_cache_stream_key",
906
+ resolve_prompt_cache_key(cfg),
907
+ ),
908
+ prompt_cache_retention=resolve_prompt_cache_retention(cfg),
909
+ prompt_cache_namespace=None,
910
+ enable_thinking=resolve_llm_enable_thinking(cfg),
911
+ reasoning_effort=resolve_llm_reasoning_effort(cfg),
912
+ session_id=None,
913
+ )
914
+ cache[target_key] = client
915
+ return client, target_key
916
+
917
+
918
+ def _load_resumed_persona(session: Any) -> str | None:
919
+ """Last applied persona from the resumed session's log, or None.
920
+
921
+ Resume restores the *base* execution mode from ``session_start`` and
922
+ leaves the persona at the config default; the ``persona_switch_applied``
923
+ events (user switches, Tab cycles, approved switch_mode proposals, and
924
+ the startup application) carry the rest. Last-wins scan, same pattern as
925
+ the chat-resume runtime-settings loaders.
926
+ """
927
+ from ...session_store import read_session_events
928
+ from ..commands.chat_resume_helpers import _resolve_session_log_path
929
+
930
+ if not persona_modes_enabled(getattr(session, "cfg", None)):
931
+ return None
932
+ try:
933
+ log_path = _resolve_session_log_path(session)
934
+ except Exception: # noqa: BLE001 - resume must not fail on persona lookup
935
+ return None
936
+ if log_path is None:
937
+ return None
938
+ persona: str | None = None
939
+ try:
940
+ for event in read_session_events(log_path):
941
+ if str(event.get("type") or "") != "persona_switch_applied":
942
+ continue
943
+ payload = event.get("payload")
944
+ if isinstance(payload, dict):
945
+ candidate = str(payload.get("persona") or "").strip().lower()
946
+ if candidate:
947
+ persona = candidate
948
+ except Exception: # noqa: BLE001
949
+ return None
950
+ return persona
951
+
952
+
953
+ def _reapply_resumed_persona(*, session: Any, console: Any = None) -> None:
954
+ """Re-apply the resumed session's last persona after the state swap.
955
+
956
+ ``session_start`` restores the user's base mode; re-applying the persona
957
+ on top reproduces the narrowed mode, write scope, restore point, and
958
+ model role exactly as the clamp would have left them. The no-op Code
959
+ persona needs no application — the restored base state already is the
960
+ correct state.
961
+ """
962
+ persona = _load_resumed_persona(session)
963
+ if not persona:
964
+ return
965
+ normalized = normalize_persona(persona, _session_persona_registry(session))
966
+ if (
967
+ normalized == DEFAULT_PERSONA
968
+ and normalize_persona(getattr(session, "persona", DEFAULT_PERSONA)) == DEFAULT_PERSONA
969
+ ):
970
+ return
971
+ try:
972
+ _apply_chat_persona(session=session, persona=normalized, source="resume")
973
+ except Exception as exc: # noqa: BLE001
974
+ if console is not None:
975
+ console.print(f"[yellow]Could not restore persona {normalized}:[/yellow] {exc}")
976
+ return
977
+ if console is not None and normalized != DEFAULT_PERSONA:
978
+ console.print(f"[dim]Persona restored: {normalized}[/dim]")
979
+
980
+
981
+ def _clear_persona_restore(session: Any) -> None:
982
+ """An explicit user /mode <exec> redefines the base: restore any
983
+ persona-narrowed write scope first, then drop the restore point."""
984
+ if getattr(session, "persona_restore_mode", None) is not None:
985
+ session.allow_write_globs = getattr(session, "persona_restore_write_globs", None)
986
+ session.persona_allow_write_globs = None
987
+ session.persona_restore_mode = None
988
+ session.persona_restore_write_globs = None
989
+
990
+
991
+ def _apply_pending_persona_switch(*, session: Any, console: Any = None) -> None:
992
+ """Apply a user-approved switch_mode proposal after the turn ends.
993
+
994
+ The tool only parks the approved target in the session's
995
+ ``PersonaSwitchState``; applying it here keeps the tool surface stable for
996
+ the whole turn and reuses the exact primitive user switches go through.
997
+ """
998
+ state = getattr(session, "persona_switch_state", None)
999
+ pending = getattr(state, "pending", None)
1000
+ if not pending:
1001
+ return
1002
+ state.pending = None
1003
+ persona, _reason = pending
1004
+ try:
1005
+ _apply_chat_persona(session=session, persona=persona, source="model")
1006
+ except Exception as exc: # noqa: BLE001
1007
+ if console is not None:
1008
+ console.print(f"[red]Failed to apply approved persona switch:[/red] {exc}")
1009
+ return
1010
+ if console is not None:
1011
+ console.print(f"[dim]Persona → {persona}[/dim]")
1012
+
1013
+
1014
+ class _ConfigReloadRequiresRestart(RuntimeError):
1015
+ """Raised when a saved config cannot be applied to the current session topology."""
1016
+
1017
+
1018
+ _ROUTE_LOCAL_CLIENT_OPTIONAL_FLAGS = (
1019
+ "_reasoning_summary_supported",
1020
+ "_thinking_display_supported",
1021
+ "_thought_summaries_supported",
1022
+ "_thinking_summaries_supported",
1023
+ "_input_token_count_available",
1024
+ "_cached_content_create_disabled_reason",
1025
+ )
1026
+ _ROUTE_LOCAL_CLIENT_FALSE_FLAGS = ("_temperature_omit_after_rejection",)
1027
+ _ROUTE_LOCAL_CLIENT_ZERO_FIELDS = ("_cached_content_create_transient_failures",)
1028
+ _ROUTE_LOCAL_CLIENT_CONTAINERS = (
1029
+ ("_cached_content_by_signature", None),
1030
+ ("_disabled_prompt_cache_fields", "_disabled_prompt_cache_fields_lock"),
1031
+ ("_temperature_compat_modes", "_temperature_compat_lock"),
1032
+ ("_tool_choice_compat_disabled", "_tool_choice_compat_lock"),
1033
+ ("_tool_calling_compat_disabled", "_tool_calling_compat_lock"),
1034
+ )
1035
+
1036
+
1037
+ def _reset_route_local_client_capabilities(client: Any, *, route_changed: bool) -> None:
1038
+ if not route_changed:
1039
+ return
1040
+ for field_name in _ROUTE_LOCAL_CLIENT_OPTIONAL_FLAGS:
1041
+ if hasattr(client, field_name):
1042
+ setattr(client, field_name, None)
1043
+ for field_name in _ROUTE_LOCAL_CLIENT_FALSE_FLAGS:
1044
+ if hasattr(client, field_name):
1045
+ setattr(client, field_name, False)
1046
+ for field_name in _ROUTE_LOCAL_CLIENT_ZERO_FIELDS:
1047
+ if hasattr(client, field_name):
1048
+ setattr(client, field_name, 0)
1049
+ for field_name, lock_field_name in _ROUTE_LOCAL_CLIENT_CONTAINERS:
1050
+ container = getattr(client, field_name, None)
1051
+ clear = getattr(container, "clear", None)
1052
+ if not callable(clear):
1053
+ continue
1054
+ lock = getattr(client, lock_field_name, None) if lock_field_name else None
1055
+ if lock is None:
1056
+ clear()
1057
+ continue
1058
+ with lock:
1059
+ clear()
1060
+
1061
+
1062
+ _RELOAD_SESSION_FIELDS = (
1063
+ "cfg",
1064
+ "routing_mode",
1065
+ "max_steps",
1066
+ "api_key",
1067
+ "api_key_source",
1068
+ "model_registry",
1069
+ )
1070
+ _RELOAD_CLIENT_FIELDS = (
1071
+ "base_url",
1072
+ "provider_auth",
1073
+ "api_key",
1074
+ "model",
1075
+ "timeout_s",
1076
+ "temperature",
1077
+ "prompt_cache_key",
1078
+ "prompt_cache_retention",
1079
+ "prompt_cache_request_field_values",
1080
+ "prompt_cache_policy_metadata",
1081
+ "prompt_cache_control_enabled",
1082
+ "prompt_cache_control_ttl",
1083
+ "explicit_cached_content_enabled",
1084
+ "cached_content_ttl",
1085
+ "cached_content_min_tokens",
1086
+ "cached_content_ttl_seconds",
1087
+ "cached_content_refresh_margin_seconds",
1088
+ "_cached_content_by_signature",
1089
+ "_cached_content_create_disabled_reason",
1090
+ "_cached_content_create_transient_failures",
1091
+ "enable_thinking",
1092
+ "reasoning_effort",
1093
+ "extra_headers",
1094
+ "provider_key",
1095
+ "reasoning_trace_adapter",
1096
+ "route_identity",
1097
+ "reasoning_trace_capability",
1098
+ "provider_concurrency_caps",
1099
+ "provider_retry_settings",
1100
+ *_ROUTE_LOCAL_CLIENT_OPTIONAL_FLAGS,
1101
+ *_ROUTE_LOCAL_CLIENT_FALSE_FLAGS,
1102
+ *_ROUTE_LOCAL_CLIENT_ZERO_FIELDS,
1103
+ *tuple(field_name for field_name, _lock_name in _ROUTE_LOCAL_CLIENT_CONTAINERS),
1104
+ )
1105
+ _RELOAD_MISSING = object()
1106
+
1107
+
1108
+ def _reload_snapshot_value(value: Any) -> Any:
1109
+ if isinstance(value, (dict, list, set, tuple)):
1110
+ return copy.deepcopy(value)
1111
+ return value
1112
+
1113
+
1114
+ def _reload_clients(session: Any) -> list[Any]:
1115
+ candidates = [
1116
+ getattr(session, "client", None),
1117
+ getattr(
1118
+ getattr(session, "conversation_compactor", None),
1119
+ "compactor_client",
1120
+ None,
1121
+ ),
1122
+ ]
1123
+ clients: list[Any] = []
1124
+ seen: set[int] = set()
1125
+ for client in candidates:
1126
+ if client is None or id(client) in seen:
1127
+ continue
1128
+ seen.add(id(client))
1129
+ clients.append(client)
1130
+ return clients
1131
+
1132
+
1133
+ def _snapshot_config_reload_state(
1134
+ session: Any,
1135
+ ) -> tuple[dict[str, Any], list[tuple[Any, dict[str, Any]]]]:
1136
+ session_snapshot = {
1137
+ field_name: (
1138
+ _reload_snapshot_value(getattr(session, field_name))
1139
+ if hasattr(session, field_name)
1140
+ else _RELOAD_MISSING
1141
+ )
1142
+ for field_name in _RELOAD_SESSION_FIELDS
1143
+ }
1144
+ client_snapshots = [
1145
+ (
1146
+ client,
1147
+ {
1148
+ field_name: (
1149
+ _reload_snapshot_value(getattr(client, field_name))
1150
+ if hasattr(client, field_name)
1151
+ else _RELOAD_MISSING
1152
+ )
1153
+ for field_name in _RELOAD_CLIENT_FIELDS
1154
+ },
1155
+ )
1156
+ for client in _reload_clients(session)
1157
+ ]
1158
+ return session_snapshot, client_snapshots
1159
+
1160
+
1161
+ def _restore_config_reload_state(
1162
+ session: Any,
1163
+ snapshot: tuple[dict[str, Any], list[tuple[Any, dict[str, Any]]]],
1164
+ ) -> None:
1165
+ session_snapshot, client_snapshots = snapshot
1166
+ for field_name, value in session_snapshot.items():
1167
+ if value is _RELOAD_MISSING:
1168
+ if hasattr(session, field_name):
1169
+ delattr(session, field_name)
1170
+ continue
1171
+ setattr(session, field_name, _reload_snapshot_value(value))
1172
+ for client, fields in client_snapshots:
1173
+ for field_name, value in fields.items():
1174
+ if value is _RELOAD_MISSING:
1175
+ if hasattr(client, field_name):
1176
+ delattr(client, field_name)
1177
+ continue
1178
+ setattr(client, field_name, _reload_snapshot_value(value))
1179
+
1180
+
1181
+ def _apply_config_menu_changes_to_session(*, session: Any, cfg: AppConfig) -> None:
1182
+ snapshot = _snapshot_config_reload_state(session)
1183
+ try:
1184
+ _apply_config_menu_changes_to_session_mutating(session=session, cfg=cfg)
1185
+ except Exception:
1186
+ _restore_config_reload_state(session, snapshot)
1187
+ raise
1188
+
1189
+
1190
+ def _apply_config_menu_changes_to_session_mutating(*, session: Any, cfg: AppConfig) -> None:
1191
+ from ...config import (
1192
+ ConfigError,
1193
+ clone_cfg,
1194
+ resolve_api_key,
1195
+ resolve_llm_enable_thinking,
1196
+ resolve_llm_reasoning_effort,
1197
+ resolve_llm_timeout_s,
1198
+ resolve_prompt_cache_key,
1199
+ resolve_prompt_cache_retention,
1200
+ resolve_role_temperature,
1201
+ )
1202
+ from ...llm.cache_capabilities import resolve_effective_cache_capability
1203
+ from ...llm.cache_policy import build_prompt_cache_namespace, resolve_prompt_cache_policy
1204
+ from ...llm.metadata import (
1205
+ build_provider_route_identity,
1206
+ canonicalize_extra_headers,
1207
+ credential_scope_fingerprint,
1208
+ )
1209
+ from ...llm.protocols import (
1210
+ ANTHROPIC_MESSAGES_PROTOCOL,
1211
+ GEMINI_INTERACTIONS_PROTOCOL,
1212
+ OPENAI_COMPAT_PROTOCOL,
1213
+ get_provider_protocol_capabilities,
1214
+ resolve_reasoning_trace_capability,
1215
+ )
1216
+ from ...llm.provider_limits import resolve_provider_retry_settings
1217
+ from ...model_registry import ModelRegistry, resolve_model_provider_key
1218
+ from ...model_router import (
1219
+ ROLE_CODING,
1220
+ ROLE_COMPACTOR,
1221
+ resolve_model_for_role,
1222
+ )
1223
+ from ...profile_presets import find_preset_for_profile
1224
+ from ...profiles import get_active_profile, resolve_effective_base_url
1225
+ from ...provider_auth import create_provider_auth
1226
+
1227
+ session.cfg = clone_cfg(cfg)
1228
+ # Deprecated no-op field: kept in step with config for observability only.
1229
+ session.routing_mode = str(getattr(cfg, "routing_mode", "auto") or "auto")
1230
+ fixed_step_override = getattr(session, "chat_turn_fixed_override", None)
1231
+ session.max_steps = int(
1232
+ fixed_step_override if fixed_step_override is not None else session.cfg.max_steps
1233
+ )
1234
+ active_profile = get_active_profile(session.cfg)
1235
+ effective_base_url = resolve_effective_base_url(cfg=session.cfg, profile=active_profile)
1236
+ protocol = str(active_profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
1237
+ resolved_key = resolve_api_key(session.cfg)
1238
+ session.api_key = str(resolved_key.key or "")
1239
+ session.api_key_source = (
1240
+ f"provider-auth:{active_profile.auth_provider}"
1241
+ if active_profile.auth_provider
1242
+ else resolved_key.source
1243
+ )
1244
+ provider_auth = (
1245
+ create_provider_auth(active_profile.auth_provider) if active_profile.auth_provider else None
1246
+ )
1247
+ route_session_scope = credential_scope_fingerprint(
1248
+ getattr(getattr(session, "store", None), "session_id", None)
1249
+ )
1250
+
1251
+ timeout_s = resolve_llm_timeout_s(session.cfg)
1252
+ enable_thinking = resolve_llm_enable_thinking(session.cfg)
1253
+ reasoning_effort = resolve_llm_reasoning_effort(session.cfg)
1254
+ prompt_cache_key = getattr(
1255
+ session,
1256
+ "prompt_cache_stream_key",
1257
+ resolve_prompt_cache_key(session.cfg),
1258
+ )
1259
+ prompt_cache_retention = resolve_prompt_cache_retention(session.cfg)
1260
+ coding_temperature = resolve_role_temperature(session.cfg, role=ROLE_CODING)
1261
+ provider_retry_settings = resolve_provider_retry_settings(session.cfg)
1262
+ model_registry = ModelRegistry(cfg=session.cfg, api_key=session.api_key)
1263
+ session.model_registry = model_registry
1264
+
1265
+ def _apply_client_config(
1266
+ client: Any,
1267
+ *,
1268
+ model: str,
1269
+ role: str,
1270
+ temperature: float | None = None,
1271
+ disable_reasoning: bool = False,
1272
+ ) -> None:
1273
+ existing_route_identity = getattr(client, "route_identity", None)
1274
+ client.base_url = effective_base_url
1275
+ if hasattr(client, "provider_auth"):
1276
+ client.provider_auth = provider_auth
1277
+ client.api_key = session.api_key
1278
+ client.model = model
1279
+ client.timeout_s = timeout_s
1280
+ if temperature is not None:
1281
+ client.temperature = temperature
1282
+ provider_key = resolve_model_provider_key(
1283
+ cfg=session.cfg,
1284
+ model_name=model,
1285
+ base_url=effective_base_url,
1286
+ profile_name=active_profile.name,
1287
+ )
1288
+ capabilities = get_provider_protocol_capabilities(
1289
+ provider_key=provider_key,
1290
+ protocol=protocol,
1291
+ )
1292
+ preset = find_preset_for_profile(active_profile)
1293
+ cache_capability = resolve_effective_cache_capability(
1294
+ provider_key=provider_key,
1295
+ protocol=protocol,
1296
+ model=model,
1297
+ base_url=effective_base_url,
1298
+ transport_capabilities=capabilities,
1299
+ preset_cache_capability=(preset.cache_capability if preset is not None else None),
1300
+ profile_cache_capability=active_profile.cache_capability,
1301
+ )
1302
+ cache_policy = resolve_prompt_cache_policy(
1303
+ cfg=session.cfg,
1304
+ capabilities=capabilities,
1305
+ provider_key=provider_key,
1306
+ protocol=protocol,
1307
+ model=model,
1308
+ prompt_cache_key=prompt_cache_key,
1309
+ prompt_cache_retention=prompt_cache_retention,
1310
+ prompt_cache_namespace=build_prompt_cache_namespace(
1311
+ workspace_root=getattr(session, "root", None),
1312
+ role=role,
1313
+ profile_name=active_profile.name,
1314
+ ),
1315
+ cache_capability=cache_capability,
1316
+ )
1317
+ credential_scope = credential_scope_fingerprint(session.api_key)
1318
+ if provider_auth is not None:
1319
+ auth_scope = getattr(provider_auth, "route_credential_scope", None)
1320
+ if callable(auth_scope):
1321
+ credential_scope = str(auth_scope() or "").strip() or credential_scope
1322
+ protocol_revision = ""
1323
+ if protocol == ANTHROPIC_MESSAGES_PROTOCOL:
1324
+ from ...llm import anthropic_messages
1325
+
1326
+ protocol_revision = anthropic_messages.ANTHROPIC_MESSAGES_ROUTE_REVISION
1327
+ elif protocol == GEMINI_INTERACTIONS_PROTOCOL:
1328
+ from ...llm import gemini_interactions
1329
+
1330
+ protocol_revision = gemini_interactions.GEMINI_INTERACTIONS_ROUTE_REVISION
1331
+ next_route_identity = build_provider_route_identity(
1332
+ protocol=protocol,
1333
+ base_url=effective_base_url,
1334
+ provider_key=provider_key,
1335
+ model=model,
1336
+ profile_name=active_profile.name,
1337
+ auth_provider=active_profile.auth_provider,
1338
+ credential_scope=credential_scope,
1339
+ routing_headers=active_profile.extra_headers,
1340
+ routing_fields=dict(cache_policy.request_field_values),
1341
+ reasoning_state_adapter=active_profile.reasoning_trace_adapter,
1342
+ protocol_revision=protocol_revision,
1343
+ session_scope=(
1344
+ route_session_scope
1345
+ or str(getattr(existing_route_identity, "session_scope", "") or "")
1346
+ ),
1347
+ )
1348
+ route_changed = (
1349
+ str(getattr(existing_route_identity, "fingerprint", "") or "")
1350
+ != next_route_identity.fingerprint
1351
+ )
1352
+ client.route_identity = next_route_identity
1353
+ _reset_route_local_client_capabilities(client, route_changed=route_changed)
1354
+ client.prompt_cache_key = cache_policy.prompt_cache_key
1355
+ client.prompt_cache_retention = cache_policy.prompt_cache_retention
1356
+ if hasattr(client, "prompt_cache_request_field_values"):
1357
+ client.prompt_cache_request_field_values = dict(cache_policy.request_field_values)
1358
+ if hasattr(client, "prompt_cache_policy_metadata"):
1359
+ client.prompt_cache_policy_metadata = cache_policy.telemetry_metadata()
1360
+ if hasattr(client, "prompt_cache_control_enabled"):
1361
+ client.prompt_cache_control_enabled = cache_policy.anthropic_cache_control_enabled
1362
+ if hasattr(client, "prompt_cache_control_ttl"):
1363
+ client.prompt_cache_control_ttl = cache_policy.anthropic_cache_control_ttl
1364
+ if hasattr(client, "explicit_cached_content_enabled") or hasattr(
1365
+ client,
1366
+ "cached_content_ttl",
1367
+ ):
1368
+ previous_enabled = bool(getattr(client, "explicit_cached_content_enabled", False))
1369
+ previous_ttl = str(getattr(client, "cached_content_ttl", "") or "")
1370
+ previous_min_tokens = getattr(client, "cached_content_min_tokens", None)
1371
+ next_enabled = bool(cache_policy.gemini_explicit_cached_content_enabled)
1372
+ next_ttl = str(cache_policy.gemini_cached_content_ttl or "3600s").strip() or "3600s"
1373
+ next_min_tokens = (
1374
+ int(cache_policy.min_cacheable_tokens)
1375
+ if cache_policy.min_cacheable_tokens is not None
1376
+ else previous_min_tokens
1377
+ )
1378
+ apply_cache_settings = getattr(client, "apply_cache_settings", None)
1379
+ if callable(apply_cache_settings):
1380
+ apply_cache_settings(
1381
+ enabled=next_enabled,
1382
+ ttl=next_ttl,
1383
+ min_tokens=(
1384
+ max(0, int(next_min_tokens)) if next_min_tokens is not None else None
1385
+ ),
1386
+ )
1387
+ else:
1388
+ if hasattr(client, "explicit_cached_content_enabled"):
1389
+ client.explicit_cached_content_enabled = next_enabled
1390
+ if hasattr(client, "cached_content_ttl"):
1391
+ client.cached_content_ttl = next_ttl
1392
+ if hasattr(client, "cached_content_min_tokens") and next_min_tokens is not None:
1393
+ client.cached_content_min_tokens = max(0, int(next_min_tokens))
1394
+ if (
1395
+ previous_enabled != next_enabled
1396
+ or previous_ttl != next_ttl
1397
+ or previous_min_tokens != next_min_tokens
1398
+ ):
1399
+ cached_content_by_signature = getattr(
1400
+ client,
1401
+ "_cached_content_by_signature",
1402
+ None,
1403
+ )
1404
+ clear_cached_content = getattr(cached_content_by_signature, "clear", None)
1405
+ if callable(clear_cached_content):
1406
+ clear_cached_content()
1407
+ # The router/classification client is built reasoning-off in session.py so
1408
+ # latency-sensitive routing/non-repo turns stay fast on slow reasoning
1409
+ # models (e.g. Xiaomi MiMo via the trial proxy). Re-applying the
1410
+ # session-wide reasoning settings here would silently re-enable that
1411
+ # chain-of-thought and reintroduce the timeout->clarification-fallback bug,
1412
+ # so honor disable_reasoning for that client.
1413
+ if disable_reasoning:
1414
+ client.enable_thinking = False
1415
+ client.reasoning_effort = None
1416
+ else:
1417
+ client.enable_thinking = enable_thinking
1418
+ client.reasoning_effort = reasoning_effort
1419
+ if hasattr(client, "extra_headers"):
1420
+ client.extra_headers = canonicalize_extra_headers(active_profile.extra_headers)
1421
+ if hasattr(client, "provider_key"):
1422
+ client.provider_key = provider_key
1423
+ if hasattr(client, "reasoning_trace_adapter"):
1424
+ client.reasoning_trace_adapter = active_profile.reasoning_trace_adapter
1425
+ model_meta = model_registry.get(model, include_provider_auth=False)
1426
+ model_supports_reasoning = model_meta.supports_reasoning
1427
+ model_capability_source = model_meta.field_sources.get("supports_reasoning")
1428
+ if active_profile.auth_provider and active_profile.reasoning_effort is not None:
1429
+ model_supports_reasoning = True
1430
+ model_capability_source = "profile:reasoning_effort"
1431
+ client.reasoning_trace_capability = resolve_reasoning_trace_capability(
1432
+ provider_key=provider_key,
1433
+ protocol=protocol,
1434
+ adapter_override=active_profile.reasoning_trace_adapter,
1435
+ model_supports_reasoning=model_supports_reasoning,
1436
+ model_capability_source=model_capability_source,
1437
+ )
1438
+ if hasattr(client, "provider_concurrency_caps"):
1439
+ client.provider_concurrency_caps = dict(session.cfg.provider_concurrency_caps)
1440
+ if hasattr(client, "provider_retry_settings"):
1441
+ client.provider_retry_settings = provider_retry_settings
1442
+
1443
+ client = getattr(session, "client", None)
1444
+ if client is not None:
1445
+ _apply_client_config(
1446
+ client,
1447
+ model=str(session.cfg.model or ""),
1448
+ role=ROLE_CODING,
1449
+ temperature=coding_temperature,
1450
+ )
1451
+
1452
+ compactor = getattr(session, "conversation_compactor", None)
1453
+ compactor_client = getattr(compactor, "compactor_client", None)
1454
+ if compactor_client is not None:
1455
+ compactor_model = str(session.cfg.model or "")
1456
+ try:
1457
+ compactor_model = resolve_model_for_role(
1458
+ cfg=session.cfg,
1459
+ role=ROLE_COMPACTOR,
1460
+ plan=None,
1461
+ )
1462
+ except ConfigError:
1463
+ compactor_model = str(session.cfg.model or "")
1464
+ _apply_client_config(
1465
+ compactor_client,
1466
+ model=compactor_model,
1467
+ role=ROLE_COMPACTOR,
1468
+ temperature=resolve_role_temperature(session.cfg, role=ROLE_COMPACTOR),
1469
+ )
1470
+
1471
+ _rebuild_session_tools_for_mode(
1472
+ session=session,
1473
+ mode=str(getattr(session, "mode", "review") or "review"),
1474
+ )
1475
+ refresh_session_environment_context_message(session)
1476
+ _refresh_chat_hud_context_cache(session)
1477
+
1478
+
1479
+ def _resolve_interactive_plan_mode_request(
1480
+ *,
1481
+ session: Any,
1482
+ console: Console,
1483
+ plan_mode_state: Any,
1484
+ user_message: str,
1485
+ plan_mode_escape_supported: bool = False,
1486
+ ) -> str | _ChatExecutionRequest:
1487
+ _ = session
1488
+ if _is_plan_mode_execute_now_follow_up(user_message):
1489
+ for line in _plan_mode_execute_now_guidance_lines(
1490
+ plan_mode_state=plan_mode_state, plan_mode_escape_supported=plan_mode_escape_supported
1491
+ ):
1492
+ console.print(line)
1493
+ return "handled"
1494
+ from ...interactive_plan_mode import INTERACTIVE_PLAN_MODE_SYSTEM_PROMPT
1495
+
1496
+ return _ChatExecutionRequest(
1497
+ instruction=user_message,
1498
+ routing_mode_override="code_only",
1499
+ ephemeral_system_messages=(INTERACTIVE_PLAN_MODE_SYSTEM_PROMPT,),
1500
+ plan_mode_capture_task=user_message,
1501
+ )
1502
+
1503
+
1504
+ def _clone_chat_startup_messages(session: Any) -> list[dict[str, Any]]:
1505
+ startup_messages_obj = getattr(session, "startup_messages", None)
1506
+ if isinstance(startup_messages_obj, list) and startup_messages_obj:
1507
+ cloned = [dict(message) for message in startup_messages_obj if isinstance(message, dict)]
1508
+ if cloned:
1509
+ return cloned
1510
+
1511
+ messages_obj = getattr(session, "messages", None)
1512
+ pinned_prefix_len = max(0, int(getattr(session, "pinned_prefix_len", 0) or 0))
1513
+ if not isinstance(messages_obj, list) or pinned_prefix_len <= 0:
1514
+ return []
1515
+ return [
1516
+ dict(message) for message in messages_obj[:pinned_prefix_len] if isinstance(message, dict)
1517
+ ]
1518
+
1519
+
1520
+ def _clear_chat_conversation(*, session: Any, pending_images: list[str]) -> None:
1521
+ startup_messages = _clone_chat_startup_messages(session)
1522
+ if not startup_messages:
1523
+ raise RuntimeError("session startup messages unavailable")
1524
+
1525
+ store = getattr(session, "store", None)
1526
+ if store is None or not hasattr(store, "append"):
1527
+ raise RuntimeError("session store unavailable")
1528
+ store.append("conversation_cleared", {"trigger": "user_command"})
1529
+
1530
+ session.messages = startup_messages
1531
+ invalidate_request_context = getattr(session, "invalidate_request_context", None)
1532
+ if callable(invalidate_request_context):
1533
+ invalidate_request_context(reason="conversation_cleared")
1534
+ elif hasattr(session, "request_context_measurement"):
1535
+ session.request_context_measurement = None
1536
+ refresh_session_workspace_binding_context_message(session)
1537
+ refresh_session_environment_context_message(session)
1538
+
1539
+ compactor = getattr(session, "conversation_compactor", None)
1540
+ if compactor is not None and hasattr(compactor, "state"):
1541
+ pinned_prefix_len = max(0, int(getattr(compactor.state, "pinned_prefix_len", 0) or 0))
1542
+ compactor.state = CompactionState(
1543
+ summary={},
1544
+ history_chunk_index=0,
1545
+ memory_message_index=None,
1546
+ pinned_prefix_len=pinned_prefix_len,
1547
+ pins=[],
1548
+ pins_message_index=None,
1549
+ )
1550
+
1551
+ pending_images.clear()
1552
+ _refresh_chat_hud_context_cache(session)
1553
+
1554
+
1555
+ def _handle_chat_command(*args: Any, **kwargs: Any) -> Any:
1556
+ from . import commands as _commands
1557
+
1558
+ _commands._sync_command_globals(globals())
1559
+ return _commands._handle_chat_command(*args, **kwargs)
1560
+
1561
+
1562
+ def _handle_forge_chat_command(*args: Any, **kwargs: Any) -> Any:
1563
+ from . import commands as _commands
1564
+
1565
+ _commands._sync_command_globals(globals())
1566
+ return _commands._handle_forge_chat_command(*args, **kwargs)
1567
+
1568
+
1569
+ def _finalize_deferred_forge_execution(
1570
+ *,
1571
+ surface: Any,
1572
+ token: Any,
1573
+ paths: Any,
1574
+ forge_state: Any,
1575
+ captured: str,
1576
+ completed: bool,
1577
+ ) -> None:
1578
+ """Close out a TUI ``/execute plan`` worker run honestly.
1579
+
1580
+ A cancelled token does NOT mean the run died: ``run_swarm`` never receives
1581
+ the token, so a soft-interrupt at any point still lets the swarm run to its
1582
+ natural end and the handler return normally (``completed``). The old logic
1583
+ keyed everything on the token alone, which silently discarded a finished
1584
+ run's entire completion output and froze the view as "Interrupted." — the
1585
+ user never learned the run had actually completed.
1586
+ """
1587
+ if surface is None:
1588
+ return
1589
+ cancelled = bool(getattr(token, "is_cancelled", False))
1590
+ run_attempted = bool(getattr(forge_state, "swarm_run_attempted", False))
1591
+ out = str(captured or "").rstrip("\n")
1592
+ if out and (completed or not cancelled):
1593
+ try:
1594
+ surface.append_system(out)
1595
+ except Exception:
1596
+ pass
1597
+ try:
1598
+ if cancelled and not completed:
1599
+ interrupt_forge = getattr(surface, "interrupt_forge", None)
1600
+ if callable(interrupt_forge):
1601
+ interrupt_forge()
1602
+ elif completed and not run_attempted:
1603
+ # The handler rejected the execute before run_swarm (empty plan, no
1604
+ # execution-ready tasks, guard failure): say so instead of freezing
1605
+ # a "Finished · 0 done · N remaining" table for a run that never
1606
+ # started (the rejection reason is in the note above).
1607
+ surface.end_forge(
1608
+ summary="Execution did not start — see the note above.",
1609
+ paths=paths,
1610
+ )
1611
+ elif cancelled:
1612
+ # The Esc-time UI handler already froze the view as "Interrupted."
1613
+ # and cleared the surface's run paths; re-arm them so the frozen
1614
+ # table reflects the statuses the finished swarm actually wrote.
1615
+ surface.end_forge(paths=paths)
1616
+ else:
1617
+ surface.end_forge()
1618
+ except Exception:
1619
+ pass
1620
+
1621
+
1622
+ def _planner_workspace_context_for_session(*, session: Any) -> dict[str, Any] | None:
1623
+ cached = getattr(session, "planner_workspace_context", None)
1624
+ if isinstance(cached, dict):
1625
+ return cached
1626
+ root_obj = getattr(session, "root", None)
1627
+ if root_obj is None:
1628
+ return None
1629
+ try:
1630
+ workspace_root = Path(root_obj)
1631
+ workspace_context = resolve_workspace_context(workspace_root)
1632
+ scan = scan_workspace(context=workspace_context)
1633
+ payload = scan.to_dict()
1634
+ except Exception: # noqa: BLE001
1635
+ return None
1636
+ try:
1637
+ session.planner_workspace_context = payload
1638
+ except Exception: # noqa: BLE001
1639
+ pass
1640
+ return payload
1641
+
1642
+
1643
+ def _should_defer_forge_planner_submission(*, forge_state: Any, text: str) -> bool:
1644
+ """Return whether a Forge submission will perform a planner model call.
1645
+
1646
+ Plain text in Forge is planner input only while the planner assistant is
1647
+ enabled. Slash/colon commands remain on the synchronous command path because
1648
+ they are local state operations (except explicitly deferred commands such as
1649
+ ``/execute plan``).
1650
+ """
1651
+ trimmed = str(text or "").strip()
1652
+ return bool(
1653
+ trimmed
1654
+ and trimmed[:1] not in "/:"
1655
+ and getattr(forge_state, "ui_mode", None) == "forge"
1656
+ and getattr(forge_state, "assistant_enabled", False)
1657
+ )
1658
+
1659
+
1660
+ def _finish_chat_surface_activity(*, session: Any) -> None:
1661
+ surface = getattr(session, "surface", None)
1662
+ handler = getattr(surface, "on_assistant_message_done", None)
1663
+ if not callable(handler):
1664
+ return
1665
+ try:
1666
+ handler("")
1667
+ except Exception: # noqa: BLE001
1668
+ return
1669
+
1670
+
1671
+ def _run_plan_mode_approval_loop(
1672
+ *,
1673
+ session: Any,
1674
+ console: Console,
1675
+ user_message: str,
1676
+ max_iterations: int | None = None,
1677
+ action_prompt: Any | None = None,
1678
+ ) -> str | None:
1679
+ limit = max_iterations if max_iterations is not None else MAX_PLAN_ITERATIONS
1680
+ if limit <= 0:
1681
+ return None
1682
+
1683
+ try:
1684
+ from ...llm.types import LLMError as _PlanLLMError
1685
+ except Exception: # noqa: BLE001
1686
+ _PlanLLMError = RuntimeError
1687
+
1688
+ previous_plan: str | None = None
1689
+ feedback: str | None = None
1690
+ for _iteration in range(limit):
1691
+ console.print("")
1692
+ _render_labeled_chat_message(console=console, label="Request", message=user_message)
1693
+ if feedback:
1694
+ _render_labeled_chat_message(
1695
+ console=console,
1696
+ label="Revision feedback",
1697
+ message=feedback,
1698
+ )
1699
+ console.print("")
1700
+
1701
+ if previous_plan and feedback:
1702
+ _emit_plan_mode_trace(
1703
+ session=session,
1704
+ message="Revising draft plan with your feedback.",
1705
+ )
1706
+ else:
1707
+ _emit_plan_mode_trace(
1708
+ session=session,
1709
+ message="Drafting execution plan for your request.",
1710
+ )
1711
+ _emit_plan_mode_trace(
1712
+ session=session,
1713
+ message="Collecting relevant conversation context for planning.",
1714
+ full_only=True,
1715
+ )
1716
+ _emit_plan_mode_trace(
1717
+ session=session,
1718
+ message="Tools stay disabled while planning this draft.",
1719
+ full_only=True,
1720
+ )
1721
+
1722
+ session_stream = bool(getattr(session, "stream", False))
1723
+ on_text_delta = (
1724
+ _make_plan_mode_delta_trace_callback(session=session) if session_stream else None
1725
+ )
1726
+ stream_plan_draft = session_stream and on_text_delta is not None
1727
+ details: dict[str, Any] = {}
1728
+
1729
+ try:
1730
+ draft = generate_plan_draft(
1731
+ client=getattr(session, "client", None),
1732
+ session_messages=list(getattr(session, "messages", []) or []),
1733
+ user_message=user_message,
1734
+ previous_plan=previous_plan,
1735
+ feedback=feedback,
1736
+ workspace_context=_planner_workspace_context_for_session(session=session),
1737
+ stream=stream_plan_draft,
1738
+ on_text_delta=on_text_delta if stream_plan_draft else None,
1739
+ details=details,
1740
+ )
1741
+ except KeyboardInterrupt:
1742
+ _finish_chat_surface_activity(session=session)
1743
+ console.print("Plan drafting interrupted. Back to chat.")
1744
+ return None
1745
+ except (RuntimeError, _PlanLLMError) as e:
1746
+ if not stream_plan_draft:
1747
+ _finish_chat_surface_activity(session=session)
1748
+ console.print(f"[red]{e}[/red]")
1749
+ return None
1750
+
1751
+ _emit_plan_mode_trace(
1752
+ session=session,
1753
+ message="Planner stream failed; retrying once without streaming.",
1754
+ full_only=True,
1755
+ )
1756
+ details = {}
1757
+ try:
1758
+ draft = generate_plan_draft(
1759
+ client=getattr(session, "client", None),
1760
+ session_messages=list(getattr(session, "messages", []) or []),
1761
+ user_message=user_message,
1762
+ previous_plan=previous_plan,
1763
+ feedback=feedback,
1764
+ workspace_context=_planner_workspace_context_for_session(session=session),
1765
+ stream=False,
1766
+ on_text_delta=None,
1767
+ details=details,
1768
+ )
1769
+ except KeyboardInterrupt:
1770
+ _finish_chat_surface_activity(session=session)
1771
+ console.print("Plan drafting interrupted. Back to chat.")
1772
+ return None
1773
+ except (RuntimeError, _PlanLLMError) as retry_error:
1774
+ _finish_chat_surface_activity(session=session)
1775
+ console.print(f"[red]{retry_error}[/red]")
1776
+ return None
1777
+ _emit_plan_mode_trace(session=session, message="Plan draft ready for review.")
1778
+
1779
+ request_messages = details.get("request_messages")
1780
+ response = details.get("response")
1781
+ if isinstance(request_messages, list) and response is not None:
1782
+ record_plan_usage(
1783
+ session=session,
1784
+ request_messages=request_messages,
1785
+ response=response,
1786
+ )
1787
+ _refresh_chat_hud_context_cache(session)
1788
+
1789
+ _finish_chat_surface_activity(session=session)
1790
+ _render_plan_draft(console=console, draft=draft)
1791
+ console.print("")
1792
+
1793
+ if action_prompt is not None:
1794
+ action = action_prompt(console=console, user_message=user_message, draft=draft)
1795
+ else:
1796
+ action = _prompt_plan_mode_action(console=console)
1797
+ if action is None:
1798
+ return None
1799
+ if action == "approve":
1800
+ return instruction_with_approved_plan(user_message=user_message, approved_plan=draft)
1801
+ if action == "propose":
1802
+ feedback_input = _prompt_plan_mode_feedback(console=console)
1803
+ if feedback_input is None:
1804
+ return None
1805
+ if not feedback_input.strip():
1806
+ console.print("[yellow]Feedback cannot be empty.[/yellow]")
1807
+ continue
1808
+ previous_plan = draft
1809
+ feedback = feedback_input.strip()
1810
+ _emit_plan_mode_trace(
1811
+ session=session,
1812
+ message="Captured feedback and preparing a revised draft.",
1813
+ full_only=True,
1814
+ )
1815
+ console.print("[bold]Regenerating plan with your feedback...[/bold]")
1816
+ continue
1817
+
1818
+ console.print("Discarded plan. What do you want to build next?")
1819
+ return None
1820
+ console.print("[yellow]Plan iteration limit reached. Returning to prompt.[/yellow]")
1821
+ return None
1822
+
1823
+
1824
+ def _print_chat_context(*args: Any, **kwargs: Any) -> Any:
1825
+ from . import rendering as _rendering
1826
+
1827
+ _rendering._sync_rendering_globals(globals())
1828
+ return _rendering._print_chat_context(*args, **kwargs)
1829
+
1830
+
1831
+ def chat(
1832
+ path: Path = typer.Option(Path("."), "--path", help="Working directory/root."),
1833
+ create_path: bool = typer.Option(
1834
+ False,
1835
+ "--create-path",
1836
+ help="Create --path if it does not exist before binding the workspace.",
1837
+ ),
1838
+ allow_broad_workspace: bool = typer.Option(
1839
+ False,
1840
+ "--allow-broad-workspace",
1841
+ help="Allow guarded broad workspaces in non-interactive startup flows.",
1842
+ ),
1843
+ image: list[Path] | None = typer.Option(
1844
+ None,
1845
+ "--image",
1846
+ help="Queue image path(s) for the next message. Repeat --image for multiple files.",
1847
+ ),
1848
+ mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
1849
+ persona: str | None = typer.Option(
1850
+ None,
1851
+ "--persona",
1852
+ help=(
1853
+ "Persona for this run: code, architect, ask, debug, or a custom "
1854
+ "persona from .alysis_personas."
1855
+ ),
1856
+ ),
1857
+ model: str | None = typer.Option(None, "--model", help="Model override."),
1858
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
1859
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
1860
+ stream: bool | None = typer.Option(
1861
+ None,
1862
+ "--stream/--no-stream",
1863
+ help="Enable streamed assistant output.",
1864
+ ),
1865
+ max_steps: int | None = typer.Option(
1866
+ None,
1867
+ "--max-steps",
1868
+ help="Optional safety limit on agent iterations for each user turn.",
1869
+ ),
1870
+ subagents: bool | None = typer.Option(
1871
+ None,
1872
+ "--subagents/--no-subagents",
1873
+ help="Enable or disable subagent delegation for this session.",
1874
+ ),
1875
+ no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
1876
+ verify_cmd: list[str] | None = typer.Option(
1877
+ None,
1878
+ "--verify-cmd",
1879
+ help="Override verification command for this chat session (repeatable).",
1880
+ ),
1881
+ api_key_env: str | None = typer.Option(
1882
+ None,
1883
+ "--api-key-env",
1884
+ help=(
1885
+ "Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
1886
+ ),
1887
+ ),
1888
+ api_key_stdin: bool = typer.Option(
1889
+ False,
1890
+ "--api-key-stdin",
1891
+ help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
1892
+ ),
1893
+ api_key: str | None = typer.Option(
1894
+ None,
1895
+ "--api-key",
1896
+ help=(
1897
+ "UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
1898
+ "Prefer --api-key-stdin or --api-key-env."
1899
+ ),
1900
+ ),
1901
+ yes: bool = typer.Option(
1902
+ False,
1903
+ "--yes",
1904
+ help="In auto mode, skip confirmations for sensitive commands.",
1905
+ ),
1906
+ diagnostic_log: Path | None = typer.Option(
1907
+ None,
1908
+ "--diagnostic-log",
1909
+ help="Append minimal crash-safe diagnostic events to this JSONL path.",
1910
+ ),
1911
+ cli_ctx: Any = None,
1912
+ ) -> None:
1913
+ from ...llm.types import LLMError
1914
+
1915
+ console = _console()
1916
+ requested_path = path
1917
+ cfg = load_config()
1918
+ effective = clone_cfg(cfg)
1919
+ if base_url is not None:
1920
+ effective.base_url = base_url
1921
+ if model is not None:
1922
+ effective.model = model
1923
+ if temperature is not None:
1924
+ _apply_temperature_override(effective, temperature)
1925
+ current_ctx = cli_ctx if cli_ctx is not None else get_current_context(silent=True)
1926
+ non_interactive = _is_non_interactive_terminal()
1927
+ stream_source = current_ctx.get_parameter_source("stream") if current_ctx is not None else None
1928
+ path_source = current_ctx.get_parameter_source("path") if current_ctx is not None else None
1929
+ max_steps_source = (
1930
+ current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
1931
+ )
1932
+ stream_provided = stream is not None
1933
+ max_steps_provided = max_steps is not None
1934
+ if current_ctx is not None:
1935
+ stream_provided = stream_source is not None and stream_source is not ParameterSource.DEFAULT
1936
+ max_steps_provided = (
1937
+ max_steps is not None
1938
+ and max_steps_source is not None
1939
+ and max_steps_source is not ParameterSource.DEFAULT
1940
+ )
1941
+ if stream_provided and stream is not None:
1942
+ effective.stream = stream
1943
+ else:
1944
+ # Interactive chat is more readable with incremental output by default.
1945
+ effective.stream = True
1946
+ if max_steps is not None:
1947
+ effective.max_steps = max_steps
1948
+ if subagents is not None:
1949
+ effective.subagents_enabled = subagents
1950
+
1951
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
1952
+ delegated_execution = effective.execution.backend == "delegated"
1953
+
1954
+ # The interactive shell is useful even before a selected subscription can
1955
+ # serve model calls. Resolve that state separately from model configuration so
1956
+ # a disconnected account does not send users back to the classic CLI.
1957
+ tui_enabled_now = False
1958
+ if not non_interactive:
1959
+ try:
1960
+ from ..tui import is_tui_enabled as _tui_enabled
1961
+
1962
+ tui_enabled_now = bool(_tui_enabled())
1963
+ except Exception:
1964
+ tui_enabled_now = False
1965
+
1966
+ # Offer a newer release before any setup/workspace screens. No-op unless a
1967
+ # cached check found one; at most once per process (bare `alysis`
1968
+ # launches already ran it from the root callback).
1969
+ if not non_interactive:
1970
+ from ..commands.update import maybe_prompt_update_at_startup
1971
+
1972
+ maybe_prompt_update_at_startup(console=console)
1973
+
1974
+ def _read_subscription_availability() -> Any:
1975
+ if delegated_execution:
1976
+ return None
1977
+ from ..commands.startup import _subscription_availability
1978
+
1979
+ return _subscription_availability(effective)
1980
+
1981
+ subscription_availability = _read_subscription_availability()
1982
+ subscription_blocked = bool(
1983
+ subscription_availability is not None
1984
+ and subscription_availability.active
1985
+ and not subscription_availability.ready
1986
+ )
1987
+
1988
+ if not delegated_execution and not effective.model and not subscription_blocked:
1989
+ should_run_setup = False
1990
+ should_run_setup_fn = globals().get("_should_run_first_run_setup_wizard")
1991
+ if callable(should_run_setup_fn):
1992
+ try:
1993
+ should_run_setup = bool(should_run_setup_fn())
1994
+ except Exception:
1995
+ should_run_setup = False
1996
+ setup_fn = globals().get("_maybe_run_first_run_setup_wizard")
1997
+ if not non_interactive and should_run_setup and callable(setup_fn):
1998
+ console.print("[yellow]No model is configured yet. Starting first-run setup.[/yellow]")
1999
+ if not bool(setup_fn()):
2000
+ return
2001
+ effective = clone_cfg(load_config())
2002
+ if base_url is not None:
2003
+ effective.base_url = base_url
2004
+ if model is not None:
2005
+ effective.model = model
2006
+ if temperature is not None:
2007
+ _apply_temperature_override(effective, temperature)
2008
+ if stream_provided and stream is not None:
2009
+ effective.stream = stream
2010
+ else:
2011
+ effective.stream = True
2012
+ if max_steps is not None:
2013
+ effective.max_steps = max_steps
2014
+ if subagents is not None:
2015
+ effective.subagents_enabled = subagents
2016
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
2017
+ delegated_execution = effective.execution.backend == "delegated"
2018
+ subscription_availability = _read_subscription_availability()
2019
+ subscription_blocked = bool(
2020
+ subscription_availability is not None
2021
+ and subscription_availability.active
2022
+ and not subscription_availability.ready
2023
+ )
2024
+ if not delegated_execution and not effective.model and not subscription_blocked:
2025
+ _print_chat_missing_model_guidance(console)
2026
+ raise typer.Exit(code=2)
2027
+ if subscription_blocked and (non_interactive or not tui_enabled_now):
2028
+ style = "red" if subscription_availability.is_error else "yellow"
2029
+ console.print(f"[{style}]{subscription_availability.message}[/{style}]")
2030
+ raise typer.Exit(code=1)
2031
+ if delegated_execution and mode is None and str(effective_mode).strip().lower() == "fullaccess":
2032
+ effective_mode = "review"
2033
+ console.print(
2034
+ "[yellow]Delegated runtimes do not inherit the native fullaccess default; "
2035
+ "using review (read-only). Pass --mode auto to allow workspace writes.[/yellow]"
2036
+ )
2037
+ binding_source = _path_binding_source(path_source, requested_path)
2038
+
2039
+ try:
2040
+ api_key_override = None
2041
+ if delegated_execution:
2042
+ from ...agent_runtimes.host import (
2043
+ prepare_delegated_runtime,
2044
+ run_delegated_chat,
2045
+ validate_delegated_cli_options,
2046
+ )
2047
+
2048
+ validate_delegated_cli_options(
2049
+ base_url=base_url,
2050
+ temperature=temperature,
2051
+ max_steps=max_steps,
2052
+ subagents=subagents,
2053
+ verify_cmd=verify_cmd,
2054
+ api_key_env=api_key_env,
2055
+ api_key_stdin=api_key_stdin,
2056
+ api_key=api_key,
2057
+ stream=stream if stream_provided else None,
2058
+ yes=yes,
2059
+ diagnostic_log=diagnostic_log,
2060
+ )
2061
+ else:
2062
+ api_key_override = _resolve_api_key_override(
2063
+ api_key=api_key,
2064
+ api_key_env=api_key_env,
2065
+ api_key_stdin=api_key_stdin,
2066
+ )
2067
+ workspace_binding = _resolve_startup_workspace_binding(
2068
+ requested_path=requested_path,
2069
+ console=console,
2070
+ interactive=not non_interactive,
2071
+ create_if_missing=create_path,
2072
+ allow_broad_workspace=allow_broad_workspace,
2073
+ source=binding_source,
2074
+ action=WorkspaceAction.CHAT,
2075
+ )
2076
+ session_root = workspace_binding.workspace_context.workspace_root
2077
+ focus_path = workspace_binding.workspace_context.focus_path
2078
+ if delegated_execution:
2079
+ prepare_delegated_runtime(effective, model=model)
2080
+ run_delegated_chat(
2081
+ cfg=effective,
2082
+ cwd=session_root,
2083
+ mode=effective_mode,
2084
+ initial_images=tuple(path.resolve() for path in (image or ())),
2085
+ no_log=no_log,
2086
+ console=console,
2087
+ )
2088
+ return
2089
+ if persona is not None:
2090
+ # --persona for this invocation: becomes the session's starting
2091
+ # persona; the startup machinery applies clamp, scope, overlay,
2092
+ # and sticky model exactly like an interactive switch. Unknown
2093
+ # names (builtin or custom-from-workspace) are rejected after
2094
+ # session creation, where the custom registry is loaded.
2095
+ if not persona_modes_enabled(effective):
2096
+ console.print("[red]Persona modes are disabled.[/red]")
2097
+ raise typer.Exit(code=2)
2098
+ effective.default_persona = str(persona).strip().lower()
2099
+ # Full-screen TUI is the default interactive chat surface. Users can
2100
+ # set ALYSIS_TUI=0 to fall back to the classic prompt loop below.
2101
+ if not non_interactive:
2102
+ if tui_enabled_now:
2103
+ import getpass as _getpass
2104
+
2105
+ from ...git_ops import current_branch as _current_branch
2106
+ from ..commands.welcome import _welcome_workspace_value
2107
+ from ..tui import TuiState as _TuiState
2108
+ from ..tui import next_exec_mode as _next_exec_mode
2109
+ from ..tui import run_tui as _run_tui
2110
+ from ..tui.app import ConfigReloadOutcome as _TuiConfigReloadOutcome
2111
+
2112
+ try:
2113
+ _username = _getpass.getuser()
2114
+ except Exception:
2115
+ _username = ""
2116
+ _ws_path = focus_path if focus_path != session_root else session_root
2117
+ try:
2118
+ _workspace = _welcome_workspace_value(_ws_path)
2119
+ except Exception:
2120
+ _workspace = ""
2121
+ try:
2122
+ _branch = _current_branch(session_root)
2123
+ except Exception:
2124
+ _branch = ""
2125
+ _tui_state = _TuiState(
2126
+ model_name=str(effective.model or ""),
2127
+ connection_status=(
2128
+ "model selection required"
2129
+ if subscription_blocked and subscription_availability.selection_required
2130
+ else "subscription not connected"
2131
+ if subscription_blocked
2132
+ else ""
2133
+ ),
2134
+ username=_username,
2135
+ workspace=_workspace,
2136
+ branch=_branch,
2137
+ exec_mode=str(effective_mode or "").strip(),
2138
+ # No approval-policy field here on purpose: ``exec_mode`` is the
2139
+ # only authority. ``--yes`` keeps its gate-level meaning (it skips
2140
+ # the fs_delete / needs_confirm confirmations inside `auto` mode,
2141
+ # see agent/tools_assembly.py) and deliberately does NOT blanket
2142
+ # auto-allow `review` prompts — that is what `/mode full` is for.
2143
+ )
2144
+ _tui_box: dict[str, Any] = {"session": None}
2145
+
2146
+ def _tui_session_builder(surface: Any) -> Any:
2147
+ built = create_session(
2148
+ # A /config save that happened before the first message
2149
+ # parks the reloaded config here (see _tui_on_config_saved)
2150
+ # so the lazy build uses it without a full TUI restart.
2151
+ cfg=_tui_box.get("cfg_override") or effective,
2152
+ root=session_root,
2153
+ mode=effective_mode,
2154
+ runtime_kind=RuntimeKind.INTERACTIVE_CHAT,
2155
+ yes=yes,
2156
+ max_steps=effective.max_steps,
2157
+ no_log=no_log,
2158
+ api_key_override=api_key_override,
2159
+ console=None,
2160
+ surface=surface,
2161
+ non_interactive=non_interactive,
2162
+ enable_chat_turn_step_budget=True,
2163
+ chat_turn_fixed_override=(
2164
+ effective.max_steps if max_steps_provided else None
2165
+ ),
2166
+ verify_cmd=verify_cmd,
2167
+ subagents_enabled=effective.subagents_enabled,
2168
+ workspace_binding=workspace_binding,
2169
+ )
2170
+ built._alysis_tui_interactive = True
2171
+ _set_chat_usage_hud_enabled(built, _resolve_usage_hud_default(effective))
2172
+ _apply_startup_persona(session=built, console=None)
2173
+ _sync_tui_session_state(_tui_state, built, include_exec_mode=True)
2174
+ _refresh_chat_hud_context_cache(built)
2175
+ _tui_box["session"] = built
2176
+ return built
2177
+
2178
+ def _tui_on_turn_complete() -> None:
2179
+ built = _tui_box.get("session")
2180
+ if built is None:
2181
+ return
2182
+ # An approved switch_mode proposal applies at turn end; the
2183
+ # classic loop does this in its turn-finally, and this hook
2184
+ # is the TUI's turn-end equivalent. Silent on success (the
2185
+ # badge flip is the feedback, matching Tab cycling); the
2186
+ # badge sync below picks up the new persona + mode.
2187
+ try:
2188
+ _apply_pending_persona_switch(session=built, console=None)
2189
+ except Exception: # noqa: BLE001 - HUD refresh must still run
2190
+ pass
2191
+ _sync_tui_session_state(_tui_state, built, include_exec_mode=True)
2192
+ from ..commands.startup import (
2193
+ _chat_context_percent_value,
2194
+ _format_usage_billing_for_display,
2195
+ _known_cost_value,
2196
+ )
2197
+
2198
+ try:
2199
+ _refresh_chat_hud_context_cache(built)
2200
+ # The footer reports provider-usable input capacity so
2201
+ # its "context" label agrees with /context and /status.
2202
+ pct = _chat_context_percent_value(built)
2203
+ if pct is not None:
2204
+ _tui_state.context_pct = float(pct)
2205
+ except Exception:
2206
+ pass
2207
+ try:
2208
+ summary = getattr(built, "usage_summary", None)
2209
+ if summary is not None:
2210
+ totals = summary.totals()
2211
+ _tui_state.tokens = int(totals.get("total_tokens") or 0)
2212
+ cost = _known_cost_value(totals)
2213
+ unknown_calls = int(totals.get("unknown_cost_calls") or 0)
2214
+ if cost is not None:
2215
+ _tui_state.cost_usd = float(cost)
2216
+ elif unknown_calls > 0:
2217
+ # Real usage but pricing is unavailable → honest "n/a",
2218
+ # never a fake $0.0000 (e.g. the unmetered MiMo trial).
2219
+ _tui_state.cost_usd = None
2220
+ else:
2221
+ # Nothing metered and nothing unmetered yet → $0.0000.
2222
+ _tui_state.cost_usd = 0.0
2223
+ _tui_state.cost_unknown_calls = unknown_calls
2224
+ _tui_state.cost_display = _format_usage_billing_for_display(
2225
+ totals, compact=True
2226
+ )
2227
+ except Exception:
2228
+ pass
2229
+ _sync_tui_session_state(_tui_state, built)
2230
+
2231
+ def _tui_config_flow_factory() -> Any:
2232
+ # Built fresh each time bare /config opens, so it always reflects
2233
+ # the on-disk config (and any edits applied since launch). The live
2234
+ # session root is passed for display + so "switch project" knows
2235
+ # what it's leaving.
2236
+ from ..tui.config_flow import ConfigFlow
2237
+
2238
+ built = _tui_box.get("session")
2239
+ root = (
2240
+ str(getattr(built, "root", "") or "")
2241
+ if built is not None
2242
+ else str(session_root)
2243
+ )
2244
+ flow = ConfigFlow(current_workspace=root)
2245
+ if subscription_blocked and subscription_availability.selection_required:
2246
+ flow.open_default_model()
2247
+ return flow
2248
+
2249
+ def _tui_on_config_saved() -> _TuiConfigReloadOutcome:
2250
+ # After the overlay saves, reload the live session from disk so the
2251
+ # new model/keys/limits apply on the next turn (same reload the
2252
+ # classic /config uses), and refresh the footer's model badge.
2253
+ # The explicit outcome lets the TUI stop draining queued work when
2254
+ # this callback has already scheduled a safe session restart.
2255
+ built = _tui_box.get("session")
2256
+ if built is None:
2257
+ # No live session yet (the common launch → /config → save
2258
+ # path): there is nothing to rebuild, so do NOT tear the
2259
+ # TUI down for a slow full restart — hand the freshly
2260
+ # saved config to the lazy session builder and keep the
2261
+ # footer in sync. CLI connection flags stay authoritative.
2262
+ try:
2263
+ from ...config import clone_cfg
2264
+
2265
+ reloaded = clone_cfg(load_config())
2266
+ if base_url is not None:
2267
+ reloaded.base_url = base_url
2268
+ if model is not None:
2269
+ reloaded.model = model
2270
+ _tui_box["cfg_override"] = reloaded
2271
+ _tui_state.model_name = str(getattr(reloaded, "model", "") or "")
2272
+ return _TuiConfigReloadOutcome.APPLIED
2273
+ except Exception:
2274
+ from prompt_toolkit.application.current import get_app
2275
+
2276
+ get_app().exit(result=("restart_config", str(session_root)))
2277
+ return _TuiConfigReloadOutcome.RESTART
2278
+ try:
2279
+ from ...config import clone_cfg
2280
+ from ...profiles import connection_fingerprint
2281
+
2282
+ reloaded = clone_cfg(load_config())
2283
+ # Command-line connection overrides remain authoritative for
2284
+ # this invocation. Reapply them before fingerprinting so a
2285
+ # routing-only save is not mistaken for a persisted connection
2286
+ # change, and before the live reload so /config cannot silently
2287
+ # replace the active CLI model or endpoint.
2288
+ if base_url is not None:
2289
+ reloaded.base_url = base_url
2290
+ if model is not None:
2291
+ reloaded.model = model
2292
+ if connection_fingerprint(reloaded) != connection_fingerprint(built.cfg):
2293
+ from prompt_toolkit.application.current import get_app
2294
+
2295
+ get_app().exit(
2296
+ result=(
2297
+ "restart_config",
2298
+ str(getattr(built, "root", "") or "."),
2299
+ )
2300
+ )
2301
+ return _TuiConfigReloadOutcome.RESTART
2302
+ _apply_config_menu_changes_to_session(session=built, cfg=reloaded)
2303
+ _tui_state.model_name = str(getattr(reloaded, "model", "") or "")
2304
+ new_mode = str(getattr(built, "mode", "") or "").strip()
2305
+ if new_mode:
2306
+ _tui_state.exec_mode = new_mode
2307
+ except _ConfigReloadRequiresRestart:
2308
+ from prompt_toolkit.application.current import get_app
2309
+
2310
+ get_app().exit(
2311
+ result=(
2312
+ "restart_routing_config",
2313
+ str(getattr(built, "root", "") or "."),
2314
+ )
2315
+ )
2316
+ return _TuiConfigReloadOutcome.RESTART
2317
+ except Exception:
2318
+ return _TuiConfigReloadOutcome.FAILED
2319
+ try:
2320
+ _refresh_chat_hud_context_cache(built)
2321
+ except Exception:
2322
+ pass
2323
+ return _TuiConfigReloadOutcome.APPLIED
2324
+
2325
+ _tui_forge_state = _ForgeChatState()
2326
+ _tui_plan_state = _ChatPlanModeState()
2327
+
2328
+ def _tui_make_forge_execute(command_text: str):
2329
+ # Build the callable the worker thread runs for "/execute plan":
2330
+ # it opens the live forge view, runs the swarm (whose events
2331
+ # stream into that view via TuiSurface.on_swarm_event), and
2332
+ # surfaces the handler's captured warnings/summary at the end.
2333
+ def _run(token: Any) -> None:
2334
+ import io as _io
2335
+
2336
+ from rich.console import Console as _RichConsole
2337
+
2338
+ built = _tui_box.get("session")
2339
+ if built is None:
2340
+ return
2341
+ surface = getattr(built, "surface", None)
2342
+ paths = getattr(_tui_forge_state, "paths", None)
2343
+ buf = _io.StringIO()
2344
+ cap = _RichConsole(
2345
+ file=buf,
2346
+ force_terminal=False,
2347
+ no_color=True,
2348
+ highlight=False,
2349
+ width=100,
2350
+ )
2351
+ if surface is not None and paths is not None:
2352
+ try:
2353
+ # Pass the worker token so the trace sink's own thread
2354
+ # can drop events after a soft-interrupt.
2355
+ surface.begin_forge(paths, token)
2356
+ except Exception:
2357
+ pass
2358
+
2359
+ def _report_sink(report_md: Any) -> None:
2360
+ # Render the run's completion answer as a real assistant
2361
+ # markdown block. Deliberately NOT gated on the token: a
2362
+ # soft-interrupt cannot stop run_swarm, so by the time the
2363
+ # handler emits this report the swarm ran to its natural
2364
+ # end — dropping the answer would hide a finished run. The
2365
+ # interrupt is acknowledged with a preface instead.
2366
+ if surface is None:
2367
+ return
2368
+ text = str(report_md or "").strip()
2369
+ if not text:
2370
+ return
2371
+ if bool(getattr(token, "is_cancelled", False)):
2372
+ text = (
2373
+ "_The interrupt could not stop the running swarm; "
2374
+ "it ran to completion._\n\n" + text
2375
+ )
2376
+ try:
2377
+ surface.append_note(text, role="assistant")
2378
+ except Exception:
2379
+ pass
2380
+
2381
+ completed = False
2382
+ try:
2383
+ _handle_chat_command(
2384
+ input_text=command_text,
2385
+ root=focus_path,
2386
+ session=built,
2387
+ pending_images=[],
2388
+ console=cap,
2389
+ forge_state=_tui_forge_state,
2390
+ plan_mode_state=_tui_plan_state,
2391
+ plan_mode_escape_supported=False,
2392
+ forge_execution_report_sink=_report_sink,
2393
+ )
2394
+ completed = True
2395
+ except Exception as _exec_exc: # noqa: BLE001
2396
+ if surface is not None:
2397
+ try:
2398
+ surface.append_system(f"Forge execution failed: {_exec_exc}")
2399
+ except Exception:
2400
+ pass
2401
+ finally:
2402
+ _finalize_deferred_forge_execution(
2403
+ surface=surface,
2404
+ token=token,
2405
+ paths=paths,
2406
+ forge_state=_tui_forge_state,
2407
+ captured=buf.getvalue(),
2408
+ completed=completed,
2409
+ )
2410
+
2411
+ return _run
2412
+
2413
+ def _tui_make_forge_planner_execute(command_text: str, width: int):
2414
+ """Build the worker job for a plain Forge planner message."""
2415
+
2416
+ def _run(token: Any) -> None:
2417
+ import io as _io
2418
+
2419
+ from rich.console import Console as _RichConsole
2420
+
2421
+ built = _tui_box.get("session")
2422
+ if built is None:
2423
+ return
2424
+ surface = getattr(built, "surface", None)
2425
+ buf = _io.StringIO()
2426
+ # Capture UNWRAPPED: the planmeta renderer reconstructs
2427
+ # logical notes from the captured "│ …" bar lines and
2428
+ # re-wraps them at the live panel width, so wrapping here
2429
+ # would only mangle notes into bar-less continuation
2430
+ # fragments (soft_wrap also stops Rich character-folding
2431
+ # a single giant token, e.g. a provider error blob).
2432
+ cap = _RichConsole(
2433
+ file=buf,
2434
+ force_terminal=False,
2435
+ no_color=True,
2436
+ highlight=False,
2437
+ soft_wrap=True,
2438
+ width=4096,
2439
+ )
2440
+
2441
+ def _planner_reply_sink(message: Any, questions: Any) -> None:
2442
+ # Render the planner reply as a real assistant markdown
2443
+ # block (styled like any chat turn) instead of dumping the
2444
+ # captured, flat "Planner: …" console text as a system line.
2445
+ if surface is None or getattr(token, "is_cancelled", False):
2446
+ return
2447
+ text = str(message or "").strip()
2448
+ asks = [str(q).strip() for q in (questions or []) if str(q).strip()]
2449
+ parts: list[str] = []
2450
+ if text:
2451
+ parts.append(text)
2452
+ if asks:
2453
+ if parts:
2454
+ parts.append("")
2455
+ parts.append("**A few questions to sharpen the plan:**")
2456
+ parts.extend(f"- {q}" for q in asks)
2457
+ body = "\n".join(parts).strip()
2458
+ if body:
2459
+ try:
2460
+ surface.append_note(body, role="assistant")
2461
+ except Exception:
2462
+ pass
2463
+
2464
+ try:
2465
+ _handle_chat_command(
2466
+ input_text=command_text,
2467
+ root=focus_path,
2468
+ session=built,
2469
+ pending_images=[],
2470
+ console=cap,
2471
+ forge_state=_tui_forge_state,
2472
+ plan_mode_state=_tui_plan_state,
2473
+ plan_mode_escape_supported=False,
2474
+ forge_planner_reply_sink=_planner_reply_sink,
2475
+ )
2476
+ except Exception as _planner_exc: # noqa: BLE001
2477
+ if surface is not None and not getattr(token, "is_cancelled", False):
2478
+ try:
2479
+ surface.append_system(f"Forge planner failed: {_planner_exc}")
2480
+ except Exception:
2481
+ pass
2482
+ return
2483
+
2484
+ if surface is None or getattr(token, "is_cancelled", False):
2485
+ return
2486
+ output = buf.getvalue().rstrip("\n")
2487
+ if output:
2488
+ try:
2489
+ # Captured planner meta / plan-reconciliation notes go
2490
+ # into a collapsible dim aside (one line by default,
2491
+ # Ctrl+O to expand) so they never bury the reply.
2492
+ surface.append_note(output, role="planmeta")
2493
+ except Exception:
2494
+ pass
2495
+
2496
+ return _run
2497
+
2498
+ def _tui_command_runner(
2499
+ sess: Any, text: str, width: int
2500
+ ) -> tuple[str, str, str | None, dict[str, Any] | None]:
2501
+ # A plain Forge message with the planner enabled performs a
2502
+ # network/model call. Return a worker job immediately so the
2503
+ # input buffer can repaint cleared and the TUI stays responsive.
2504
+ if _should_defer_forge_planner_submission(
2505
+ forge_state=_tui_forge_state,
2506
+ text=text,
2507
+ ):
2508
+ return (
2509
+ "run",
2510
+ "",
2511
+ text.strip(),
2512
+ {
2513
+ "_deferred_execute": _tui_make_forge_planner_execute(
2514
+ text.strip(), width
2515
+ )
2516
+ },
2517
+ )
2518
+ # "/execute plan" inside Forge runs the swarm — defer it to the
2519
+ # worker thread (not the synchronous runner, which would freeze
2520
+ # the UI) with a live forge view instead of a flat captured dump.
2521
+ # The bare form is normally intercepted by the launch-gate panel
2522
+ # provider; only its "Launch now" action (a one-shot re-submit of
2523
+ # "/execute plan") reaches here. We normalize to a plain
2524
+ # "/execute plan" (the command handler rejects any other arg); the
2525
+ # chosen knobs travel out-of-band on the forge state.
2526
+ _exec_parts = text.strip().lower().split()
2527
+ if (
2528
+ _tui_forge_state.ui_mode == "forge"
2529
+ and len(_exec_parts) >= 2
2530
+ and _exec_parts[0] == "/execute"
2531
+ and _exec_parts[1] == "plan"
2532
+ ):
2533
+ return (
2534
+ "run",
2535
+ "",
2536
+ "/execute plan",
2537
+ {"_deferred_execute": _tui_make_forge_execute("/execute plan")},
2538
+ )
2539
+ # Typed "/resume <index|id>" applies natively (resolve → swap →
2540
+ # reload transcript) instead of the capture path (whose history
2541
+ # render no-ops with console=None and which can't run the picker).
2542
+ # Bare "/resume" never reaches here — the picker intercepts it.
2543
+ _resume_split = text.strip().split(maxsplit=1)
2544
+ if (
2545
+ len(_resume_split) == 2
2546
+ and _resume_split[0].lower() == "/resume"
2547
+ and _resume_split[1].strip()
2548
+ ):
2549
+ ctx = _tui_resume_context()
2550
+ if ctx is not None:
2551
+ _built_r, sessions_dir_r, current_id_r, ws_r, git_r = ctx
2552
+ raw_arg = _resume_split[1].strip()
2553
+ try:
2554
+ candidates_r = _collect_chat_resume_candidates(
2555
+ sessions_dir=sessions_dir_r,
2556
+ current_session_id=current_id_r,
2557
+ workspace_root=ws_r,
2558
+ git_root=git_r,
2559
+ )
2560
+ except Exception: # noqa: BLE001
2561
+ candidates_r = []
2562
+ target_id = _resolve_chat_resume_target(
2563
+ raw_value=raw_arg, sessions=candidates_r
2564
+ )
2565
+ if target_id is None and not raw_arg.isdigit():
2566
+ target_id = _resolve_chat_resume_direct_session_id(
2567
+ raw_value=raw_arg, sessions_dir=sessions_dir_r
2568
+ )
2569
+ if target_id is None:
2570
+ return (
2571
+ "handled",
2572
+ "Invalid session. Use /resume for the picker or "
2573
+ "/resume <index|session_id>.",
2574
+ None,
2575
+ None,
2576
+ )
2577
+ # _tui_resume_apply writes the outcome (and reloads the
2578
+ # transcript) itself, so return empty output to avoid a
2579
+ # duplicate line.
2580
+ _tui_resume_apply(target_id)
2581
+ return ("handled", "", None, None)
2582
+ # Route a submission through the chat command handler with a
2583
+ # capture console so its output renders into the TUI transcript
2584
+ # instead of corrupting the alt-screen. stdin is swapped to EOF
2585
+ # so any interactive command (e.g. /resume) cancels cleanly.
2586
+ import io as _io
2587
+ import sys as _sys
2588
+
2589
+ from rich.console import Console as _RichConsole
2590
+
2591
+ buf = _io.StringIO()
2592
+ cap = _RichConsole(
2593
+ file=buf,
2594
+ force_terminal=False,
2595
+ no_color=True,
2596
+ highlight=False,
2597
+ width=max(20, min(int(width or 100), 120)),
2598
+ )
2599
+ saved_stdin = _sys.stdin
2600
+ plan_mode_action_prompt = None
2601
+ surface_for_plan = getattr(sess, "surface", None)
2602
+ defer_plan_mode_approval = getattr(
2603
+ surface_for_plan, "defer_plan_mode_approval", None
2604
+ )
2605
+ if callable(defer_plan_mode_approval):
2606
+
2607
+ def _tui_plan_mode_action_prompt(
2608
+ *, console: Any, user_message: str, draft: str
2609
+ ) -> str | None:
2610
+ console.print(_plan_mode_actions_panel())
2611
+ console.print("Select option [1/2/3]:")
2612
+ defer_plan_mode_approval(
2613
+ user_message=user_message,
2614
+ draft=draft,
2615
+ approved_instruction=instruction_with_approved_plan(
2616
+ user_message=user_message,
2617
+ approved_plan=draft,
2618
+ ),
2619
+ )
2620
+ return None
2621
+
2622
+ plan_mode_action_prompt = _tui_plan_mode_action_prompt
2623
+ try:
2624
+ _sys.stdin = _io.StringIO("")
2625
+ result = _handle_chat_command(
2626
+ input_text=text,
2627
+ root=focus_path,
2628
+ session=sess,
2629
+ pending_images=[],
2630
+ console=cap,
2631
+ forge_state=_tui_forge_state,
2632
+ plan_mode_state=_tui_plan_state,
2633
+ plan_mode_escape_supported=False,
2634
+ plan_mode_action_prompt=plan_mode_action_prompt,
2635
+ )
2636
+ except Exception as _cmd_exc: # noqa: BLE001
2637
+ return ("handled", f"Command error: {_cmd_exc}", None, None)
2638
+ finally:
2639
+ _sys.stdin = saved_stdin
2640
+ output = buf.getvalue().rstrip("\n")
2641
+ # Keep footer badges in sync after local commands such as
2642
+ # /mode and /usage hud.
2643
+ _sync_tui_session_state(_tui_state, sess, include_exec_mode=True)
2644
+ # The forge state machine flips ui_mode inside _enter_forge_mode
2645
+ # (and back to "chat" on /back / /done); this single sync drives
2646
+ # the FORGE footer badge + the forge-specific input placeholder.
2647
+ _tui_state.forge_mode = _tui_forge_state.ui_mode == "forge"
2648
+ if _tui_state.forge_mode:
2649
+ _forge_paths = getattr(_tui_forge_state, "paths", None)
2650
+ _run_id = str(getattr(_forge_paths, "run_id", "") or "")
2651
+ _tui_state.forge_run_id = (
2652
+ _run_id if len(_run_id) <= 12 else _run_id[:11] + "…"
2653
+ )
2654
+ else:
2655
+ _tui_state.forge_run_id = ""
2656
+ if result == "exit":
2657
+ return ("exit", output, None, None)
2658
+ if result == "send":
2659
+ return ("run", output, text, {})
2660
+ if isinstance(result, _ChatExecutionRequest):
2661
+ run_kwargs: dict[str, Any] = {}
2662
+ if result.routing_mode_override:
2663
+ run_kwargs["routing_mode_override"] = result.routing_mode_override
2664
+ if result.ephemeral_system_messages:
2665
+ run_kwargs["ephemeral_system_messages"] = list(
2666
+ result.ephemeral_system_messages
2667
+ )
2668
+ if result.ephemeral_user_messages:
2669
+ run_kwargs["ephemeral_user_messages"] = list(
2670
+ result.ephemeral_user_messages
2671
+ )
2672
+ return ("run", output, result.instruction, run_kwargs)
2673
+ return ("handled", output, None, None)
2674
+
2675
+ from ..commands.chat_status import _chat_status_panel_spec
2676
+ from ..commands.chat_tui_panels import (
2677
+ _chat_asset_detail_panel_spec,
2678
+ _chat_assets_picker_spec,
2679
+ _chat_config_panel_spec,
2680
+ _chat_context_panel_spec,
2681
+ _chat_forge_intro_panel_spec,
2682
+ _chat_forge_markdown_panel_spec,
2683
+ _chat_forge_plan_panel_spec,
2684
+ _chat_model_info_panel_spec,
2685
+ _chat_skill_listing_panel_spec,
2686
+ _chat_terminals_panel_spec,
2687
+ _chat_toolbar_panel_spec,
2688
+ _chat_usage_panel_spec,
2689
+ )
2690
+
2691
+ def _tui_status_panel(arg: str = "") -> dict[str, Any] | None:
2692
+ # TUI-native /status: render the live session snapshot as a
2693
+ # centered popup instead of a flat gray table dump.
2694
+ built = _tui_box.get("session")
2695
+ if built is None:
2696
+ return None
2697
+ try:
2698
+ return _chat_status_panel_spec(session=built, pending_images=[])
2699
+ except Exception: # noqa: BLE001 - never crash the UI
2700
+ return None
2701
+
2702
+ def _tui_usage_panel(arg: str = "") -> dict[str, Any] | None:
2703
+ # Bare /usage opens the panel; "/usage hud …" falls through to
2704
+ # the command runner so the HUD toggle still applies.
2705
+ if arg.strip():
2706
+ return None
2707
+ built = _tui_box.get("session")
2708
+ if built is None:
2709
+ return None
2710
+ try:
2711
+ return _chat_usage_panel_spec(session=built)
2712
+ except Exception: # noqa: BLE001
2713
+ return None
2714
+
2715
+ def _tui_context_panel(arg: str = "") -> dict[str, Any] | None:
2716
+ if arg.strip():
2717
+ return None
2718
+ built = _tui_box.get("session")
2719
+ if built is None:
2720
+ return None
2721
+ try:
2722
+ return _chat_context_panel_spec(session=built)
2723
+ except Exception: # noqa: BLE001
2724
+ return None
2725
+
2726
+ def _tui_model_info_panel(arg: str = "") -> dict[str, Any] | None:
2727
+ built = _tui_box.get("session")
2728
+ if built is None:
2729
+ return None
2730
+ try:
2731
+ return _chat_model_info_panel_spec(session=built, model_ref=arg)
2732
+ except Exception: # noqa: BLE001
2733
+ return None
2734
+
2735
+ def _tui_config_panel(arg: str = "") -> dict[str, Any] | None:
2736
+ # Bare /config now opens the full interactive menu overlay (handled
2737
+ # in app._submit before this provider). This read-only model panel
2738
+ # serves /config show|list|help (the tracked-model view) and acts as
2739
+ # the fallback when the overlay is unavailable; set|clear|rm|delete
2740
+ # fall through to the command runner so edits still apply.
2741
+ normalized = arg.strip().lower()
2742
+ if normalized and normalized not in {"show", "list", "help"}:
2743
+ return None
2744
+ built = _tui_box.get("session")
2745
+ if built is None:
2746
+ return None
2747
+ try:
2748
+ return _chat_config_panel_spec(session=built)
2749
+ except Exception: # noqa: BLE001
2750
+ return None
2751
+
2752
+ def _tui_toolbar_panel(arg: str = "") -> dict[str, Any] | None:
2753
+ if arg.strip():
2754
+ return None
2755
+ built = _tui_box.get("session")
2756
+ if built is None:
2757
+ return None
2758
+ try:
2759
+ return _chat_toolbar_panel_spec(session=built)
2760
+ except Exception: # noqa: BLE001
2761
+ return None
2762
+
2763
+ def _tui_terminals_panel(arg: str = "") -> dict[str, Any] | None:
2764
+ # Bare /terminals (or "list") opens the panel; show|kill|help fall
2765
+ # through to the command runner.
2766
+ normalized = arg.strip().lower()
2767
+ if normalized and normalized != "list":
2768
+ return None
2769
+ built = _tui_box.get("session")
2770
+ if built is None:
2771
+ return None
2772
+ try:
2773
+ return _chat_terminals_panel_spec(session=built)
2774
+ except Exception: # noqa: BLE001
2775
+ return None
2776
+
2777
+ def _tui_skill_panel(arg: str = "") -> dict[str, Any] | None:
2778
+ # Bare /skill lists skills; "/skill <name>" and "/skill <name>
2779
+ # <task>" fall through to the runner (info text / attach turn).
2780
+ if arg.strip():
2781
+ return None
2782
+ built = _tui_box.get("session")
2783
+ if built is None:
2784
+ return None
2785
+ try:
2786
+ return _chat_skill_listing_panel_spec(session=built)
2787
+ except Exception: # noqa: BLE001
2788
+ return None
2789
+
2790
+ def _tui_forge_planner_choice(value: Any) -> dict[str, Any]:
2791
+ # A pick from the "Use the planner?" prompt → enter Forge with the
2792
+ # plan assistant forced on/off. Submitting the flagged /forge form
2793
+ # runs the full entry pipeline (workspace binding + footer sync);
2794
+ # the flag is parsed into planner_assistant_default, never a goal.
2795
+ choice = str(value or "").strip().lower()
2796
+ submit = "/forge --planner" if choice == "yes" else "/forge --no-planner"
2797
+ return {"submit": submit}
2798
+
2799
+ def _tui_forge_planner_prompt() -> dict[str, Any]:
2800
+ # on_confirm for the /forge intro popup: instead of entering Forge
2801
+ # directly, open a native picker asking whether to use the planner
2802
+ # assistant (the classic stdin prompt can't run in the alt-screen).
2803
+ return {
2804
+ "picker": {
2805
+ "title": "Use the planner assistant?",
2806
+ "hint": "↑↓ move · Enter select · Esc cancel",
2807
+ "rows": [
2808
+ {
2809
+ "value": "yes",
2810
+ "label": "Yes — use the planner assistant",
2811
+ "description": (
2812
+ "Draft and refine the plan together before building."
2813
+ ),
2814
+ "current": True,
2815
+ },
2816
+ {
2817
+ "value": "no",
2818
+ "label": "No — plan it myself",
2819
+ "description": (
2820
+ "Enter Forge and shape the plan with /goal and /task."
2821
+ ),
2822
+ },
2823
+ ],
2824
+ "on_select": _tui_forge_planner_choice,
2825
+ }
2826
+ }
2827
+
2828
+ def _tui_forge_intro_panel(arg: str = "") -> dict[str, Any] | None:
2829
+ # Plain /forge opens a guidance popup explaining how Forge works.
2830
+ # Its Enter runs on_confirm, which opens the native "Use the
2831
+ # planner?" picker; the pick enters Forge with the assistant on/off.
2832
+ # "/forge <goal>" / "/forge resume" (arg present) and re-entry while
2833
+ # already in Forge return None so they fall through to the runner
2834
+ # and enter directly (no intro, no planner prompt).
2835
+ if arg.strip():
2836
+ return None
2837
+ if _tui_forge_state.ui_mode == "forge":
2838
+ return None
2839
+ try:
2840
+ spec = _chat_forge_intro_panel_spec()
2841
+ except Exception: # noqa: BLE001 - never crash the UI on a panel
2842
+ return None
2843
+ # Swap the bare confirm-command for the planner prompt so the user
2844
+ # chooses the planner natively before Forge actually enters.
2845
+ spec.pop("confirm", None)
2846
+ spec["on_confirm"] = _tui_forge_planner_prompt
2847
+ spec["hint"] = "↵ Enter — choose planner · Esc — cancel"
2848
+ return spec
2849
+
2850
+ # --- Forge read-only panels (only fire inside a Forge session) ---
2851
+ def _tui_forge_plan_and_paths() -> tuple[Any, Any] | None:
2852
+ if _tui_forge_state.ui_mode != "forge":
2853
+ return None # chat-mode /plan, /show handled elsewhere
2854
+ plan = getattr(_tui_forge_state, "plan", None)
2855
+ paths = getattr(_tui_forge_state, "paths", None)
2856
+ if not isinstance(plan, dict) or paths is None:
2857
+ return None
2858
+ return plan, paths
2859
+
2860
+ def _tui_forge_show_panel(arg: str = "") -> dict[str, Any] | None:
2861
+ # Forge /show → the plan summary panel.
2862
+ pair = _tui_forge_plan_and_paths()
2863
+ if pair is None:
2864
+ return None
2865
+ plan, paths = pair
2866
+ try:
2867
+ return _chat_forge_plan_panel_spec(paths=paths, plan=plan)
2868
+ except Exception: # noqa: BLE001 - never crash the UI on a panel
2869
+ return None
2870
+
2871
+ def _tui_forge_plan_edit_spec(plan: Any, paths: Any) -> dict[str, Any]:
2872
+ # /plan edit → open plan.json in the in-TUI editor; on save,
2873
+ # validate the JSON shape, persist it, and reload the in-memory
2874
+ # plan (no $EDITOR, which can't run under the alt-screen).
2875
+ import json as _json
2876
+
2877
+ from ...forge import save_plan as _save_plan
2878
+
2879
+ try:
2880
+ _save_plan(paths, plan) # PLAN.md/json reflect the live plan
2881
+ except Exception:
2882
+ pass
2883
+ try:
2884
+ text = paths.plan_json_path.read_text(encoding="utf-8")
2885
+ except Exception:
2886
+ text = _json.dumps(plan, indent=2, ensure_ascii=False)
2887
+
2888
+ def _on_save(new_text: str) -> tuple[bool, str]:
2889
+ try:
2890
+ candidate = _json.loads(new_text)
2891
+ except _json.JSONDecodeError as exc:
2892
+ return (False, f"Invalid JSON: {exc}")
2893
+ from ..commands.forge_helpers import _validate_forge_plan_shape
2894
+
2895
+ shape_error = _validate_forge_plan_shape(candidate)
2896
+ if shape_error:
2897
+ return (False, shape_error)
2898
+ try:
2899
+ _save_plan(paths, candidate)
2900
+ except Exception as exc: # noqa: BLE001
2901
+ return (False, f"Failed to save plan: {exc}")
2902
+ _tui_forge_state.plan = candidate
2903
+ suffix = ""
2904
+ try:
2905
+ from ..commands.forge_helpers import (
2906
+ _validate_forge_plan_for_paths,
2907
+ )
2908
+
2909
+ warnings = _validate_forge_plan_for_paths(paths, candidate)
2910
+ if warnings:
2911
+ suffix = f" · {len(warnings)} validation warning(s)"
2912
+ except Exception:
2913
+ pass
2914
+ return (True, f"Plan saved and reloaded.{suffix}")
2915
+
2916
+ return {
2917
+ "editor": {
2918
+ "title": f"Edit plan.json · {getattr(paths, 'run_id', '')}".rstrip(
2919
+ " ·"
2920
+ ),
2921
+ "text": text,
2922
+ "on_save": _on_save,
2923
+ }
2924
+ }
2925
+
2926
+ def _tui_forge_plan_panel(arg: str = "") -> dict[str, Any] | None:
2927
+ # Forge /plan: tasks|table|view → plan panel; markdown|md → PLAN.md
2928
+ # doc panel; edit → in-TUI JSON editor. Bare /plan returns None so
2929
+ # it falls through to the forge /plan picker.
2930
+ pair = _tui_forge_plan_and_paths()
2931
+ if pair is None:
2932
+ return None
2933
+ plan, paths = pair
2934
+ sub = arg.strip().lower()
2935
+ try:
2936
+ if sub in {"tasks", "table", "view"}:
2937
+ return _chat_forge_plan_panel_spec(paths=paths, plan=plan)
2938
+ if sub in {"markdown", "md"}:
2939
+ return _chat_forge_markdown_panel_spec(paths=paths, plan=plan)
2940
+ if sub in {"edit", "edit-json"}:
2941
+ return _tui_forge_plan_edit_spec(plan, paths)
2942
+ except Exception: # noqa: BLE001
2943
+ return None
2944
+ return None
2945
+
2946
+ # --- Forge assets (picker + detail panel; replaces the stdin modal) ---
2947
+ def _tui_asset_context() -> tuple[Any, Any] | None:
2948
+ built = _tui_box.get("session")
2949
+ if built is None:
2950
+ return None
2951
+ cfg = getattr(built, "cfg", None)
2952
+ if not isinstance(cfg, AppConfig):
2953
+ cfg = effective if isinstance(effective, AppConfig) else None
2954
+ asset_paths = getattr(_tui_forge_state, "paths", None)
2955
+ if asset_paths is None:
2956
+ # Outside a Forge session, resolve the workspace's current run.
2957
+ try:
2958
+ from ...agent.prompt_context import (
2959
+ resolve_session_active_workdir_path,
2960
+ )
2961
+ from ...forge import load_current_run_paths
2962
+
2963
+ asset_paths = load_current_run_paths(
2964
+ Path(resolve_session_active_workdir_path(built))
2965
+ )
2966
+ except Exception:
2967
+ asset_paths = None
2968
+ if cfg is None or asset_paths is None:
2969
+ return None
2970
+ return cfg, asset_paths
2971
+
2972
+ def _tui_assets_panel(arg: str = "") -> dict[str, Any] | None:
2973
+ ctx = _tui_asset_context()
2974
+ if ctx is None:
2975
+ return None
2976
+ cfg_obj, asset_paths = ctx
2977
+ asset_id = arg.strip()
2978
+ if asset_id:
2979
+ try:
2980
+ return _chat_asset_detail_panel_spec(
2981
+ cfg=cfg_obj, paths=asset_paths, asset_id=asset_id
2982
+ )
2983
+ except Exception: # noqa: BLE001
2984
+ return None
2985
+ # Bare /assets: show a small panel only when there is nothing to
2986
+ # pick; otherwise fall through to the selectable picker below.
2987
+ try:
2988
+ from ...assets.surface import build_asset_surface
2989
+
2990
+ count = len(
2991
+ build_asset_surface(cfg=cfg_obj, run_paths=asset_paths).list_assets()
2992
+ )
2993
+ except Exception: # noqa: BLE001
2994
+ return None
2995
+ if count == 0:
2996
+ return {
2997
+ "title": "Assets",
2998
+ "sections": [
2999
+ (
3000
+ "Assets",
3001
+ [("status", "No assets attached to this run yet.", "plain")],
3002
+ )
3003
+ ],
3004
+ "hint": "Esc close",
3005
+ }
3006
+ return None
3007
+
3008
+ def _tui_assets_picker() -> dict[str, Any] | None:
3009
+ ctx = _tui_asset_context()
3010
+ if ctx is None:
3011
+ return None
3012
+ cfg_obj, asset_paths = ctx
3013
+ try:
3014
+ return _chat_assets_picker_spec(cfg=cfg_obj, paths=asset_paths)
3015
+ except Exception: # noqa: BLE001
3016
+ return None
3017
+
3018
+ def _tui_login_select(value: str) -> dict[str, Any]:
3019
+ return {"exit": ("login_connection", str(value))}
3020
+
3021
+ def _tui_login_picker() -> dict[str, Any]:
3022
+ from ..commands.auth import login_connection_rows
3023
+
3024
+ rows = [
3025
+ {
3026
+ "label": label,
3027
+ "description": description,
3028
+ "value": connection_id,
3029
+ }
3030
+ for connection_id, label, description in login_connection_rows()
3031
+ ]
3032
+ return {
3033
+ "title": "Log in",
3034
+ "hint": "Choose how Alysis Code should connect",
3035
+ "rows": rows,
3036
+ "on_select": _tui_login_select,
3037
+ }
3038
+
3039
+ # --- Forge launch gate: /execute plan opens a confirm + knob picker ---
3040
+ # One-shot flag: "Launch now" sets it, then re-submits a clean
3041
+ # "/execute plan"; the gate provider consumes it and falls through to
3042
+ # the runner, so the swarm launches without echoing any internal
3043
+ # sentinel into the visible transcript.
3044
+ _tui_forge_launch = {"confirmed": False}
3045
+ _FORGE_KNOB_DEFAULTS: dict[str, Any] = {
3046
+ "parallel": 2,
3047
+ "scope_mode": "strict",
3048
+ "verify_mode": "warn",
3049
+ "review": False,
3050
+ }
3051
+
3052
+ def _tui_forge_knobs() -> dict[str, Any]:
3053
+ knobs = dict(_FORGE_KNOB_DEFAULTS)
3054
+ stored = _tui_forge_state.swarm_knobs
3055
+ if isinstance(stored, dict):
3056
+ knobs.update(stored)
3057
+ return knobs
3058
+
3059
+ def _tui_forge_cycle_knob(key: str) -> None:
3060
+ knobs = _tui_forge_knobs()
3061
+ if key == "parallel":
3062
+ current = int(knobs.get("parallel", 2) or 2)
3063
+ knobs["parallel"] = current + 1 if current < 4 else 1
3064
+ elif key == "scope_mode":
3065
+ knobs["scope_mode"] = (
3066
+ "warn" if knobs.get("scope_mode") == "strict" else "strict"
3067
+ )
3068
+ elif key == "verify_mode":
3069
+ order = ["warn", "strict", "off"]
3070
+ try:
3071
+ idx = order.index(str(knobs.get("verify_mode")))
3072
+ except ValueError:
3073
+ idx = 0
3074
+ knobs["verify_mode"] = order[(idx + 1) % len(order)]
3075
+ elif key == "review":
3076
+ knobs["review"] = not bool(knobs.get("review"))
3077
+ _tui_forge_state.swarm_knobs = knobs
3078
+
3079
+ def _tui_forge_knob_blurb(key: str, knobs: dict[str, Any]) -> str:
3080
+ # One terse line describing only the *currently selected* value —
3081
+ # cycling a knob swaps the line, so each alternative explains
3082
+ # itself the moment it is picked (never an all-values glossary).
3083
+ if key == "parallel":
3084
+ n = int(knobs.get("parallel", 2) or 2)
3085
+ if n <= 1:
3086
+ return "Tasks build one at a time."
3087
+ return f"Up to {n} tasks build at once."
3088
+ if key == "scope_mode":
3089
+ if knobs.get("scope_mode") == "strict":
3090
+ return "A worker edits only its task's files."
3091
+ return "Cross-task edits allowed, just flagged in the report."
3092
+ if key == "verify_mode":
3093
+ # NB: warn still fails a task on NEW check failures — it only
3094
+ # downgrades pre-existing/baseline failures and infra problems
3095
+ # (see swarm_worker._run_authoritative_verification).
3096
+ mode = str(knobs.get("verify_mode"))
3097
+ if mode == "strict":
3098
+ return "Any check failure fails the task."
3099
+ if mode == "off":
3100
+ return "Skip verification checks."
3101
+ return "New failures fail the task; pre-existing just warn."
3102
+ if key == "review":
3103
+ if knobs.get("review"):
3104
+ return "A reviewer checks each task before it merges."
3105
+ return "Merge without a reviewer pass."
3106
+ return ""
3107
+
3108
+ def _tui_forge_launch_gate_picker(
3109
+ plan: Any, paths: Any, focus: str | None = None
3110
+ ) -> dict[str, Any]:
3111
+ knobs = _tui_forge_knobs()
3112
+ tasks = plan.get("tasks") if isinstance(plan, dict) else None
3113
+ n_tasks = len(tasks) if isinstance(tasks, list) else 0
3114
+ run_id = str(getattr(paths, "run_id", "") or "")
3115
+ all_default = all(
3116
+ knobs.get(key) == value for key, value in _FORGE_KNOB_DEFAULTS.items()
3117
+ )
3118
+
3119
+ def _default_tag(key: str) -> str:
3120
+ if knobs.get(key) == _FORGE_KNOB_DEFAULTS.get(key):
3121
+ return "(default)"
3122
+ return ""
3123
+
3124
+ rows = [
3125
+ {
3126
+ "value": "__launch__",
3127
+ "label": "Launch now",
3128
+ "description": (
3129
+ f"Build {n_tasks} task(s) — recommended defaults."
3130
+ if all_default
3131
+ else f"Build {n_tasks} task(s) with the settings below."
3132
+ ),
3133
+ "current": focus in (None, "__launch__"),
3134
+ "tag": "",
3135
+ },
3136
+ {
3137
+ "value": "parallel",
3138
+ "label": f"Workers: {knobs['parallel']}",
3139
+ "description": _tui_forge_knob_blurb("parallel", knobs),
3140
+ "current": focus == "parallel",
3141
+ "tag": _default_tag("parallel"),
3142
+ },
3143
+ {
3144
+ "value": "scope_mode",
3145
+ "label": f"File scope: {knobs['scope_mode']}",
3146
+ "description": _tui_forge_knob_blurb("scope_mode", knobs),
3147
+ "current": focus == "scope_mode",
3148
+ "tag": _default_tag("scope_mode"),
3149
+ },
3150
+ {
3151
+ "value": "verify_mode",
3152
+ "label": f"Verify: {knobs['verify_mode']}",
3153
+ "description": _tui_forge_knob_blurb("verify_mode", knobs),
3154
+ "current": focus == "verify_mode",
3155
+ "tag": _default_tag("verify_mode"),
3156
+ },
3157
+ {
3158
+ "value": "review",
3159
+ "label": f"Review: {'on' if knobs['review'] else 'off'}",
3160
+ "description": _tui_forge_knob_blurb("review", knobs),
3161
+ "current": focus == "review",
3162
+ "tag": _default_tag("review"),
3163
+ },
3164
+ ]
3165
+
3166
+ def _on_select(value: Any) -> dict[str, Any] | None:
3167
+ selected = str(value)
3168
+ if selected == "__launch__":
3169
+ # Confirm-launch: flag a one-shot bypass, then re-submit a
3170
+ # clean "/execute plan" (no sentinel echoed to the
3171
+ # transcript). The gate provider consumes the flag and the
3172
+ # runner defers the real swarm execution on the worker.
3173
+ _tui_forge_launch["confirmed"] = True
3174
+ return {"submit": "/execute plan"}
3175
+ _tui_forge_cycle_knob(selected)
3176
+ pair = _tui_forge_plan_and_paths()
3177
+ if pair is None:
3178
+ return None
3179
+ # Re-present the gate with the cycled value, keeping focus.
3180
+ return {
3181
+ "picker": _tui_forge_launch_gate_picker(
3182
+ pair[0], pair[1], focus=selected
3183
+ )
3184
+ }
3185
+
3186
+ title = (f"Launch Forge · {run_id}".rstrip(" ·")) or "Launch Forge"
3187
+ return {
3188
+ "title": title,
3189
+ "hint": "↑↓ move · Enter/1 launch · 2-5 cycle setting · Esc cancel",
3190
+ "rows": rows,
3191
+ "on_select": _on_select,
3192
+ }
3193
+
3194
+ def _tui_forge_execute_gate(arg: str = "") -> dict[str, Any] | None:
3195
+ # /execute plan → open the launch gate instead of firing the swarm
3196
+ # blind. Only the bare "plan" form opens it; any other arg returns
3197
+ # None to fall through to the runner.
3198
+ if _tui_forge_state.ui_mode != "forge":
3199
+ return None
3200
+ if arg.strip().lower() != "plan":
3201
+ return None
3202
+ if _tui_forge_launch.get("confirmed"):
3203
+ # The gate's own "Launch now" re-submitting "/execute plan":
3204
+ # consume the one-shot flag and fall through so the runner
3205
+ # defers the real execution (no second gate, no loop).
3206
+ _tui_forge_launch["confirmed"] = False
3207
+ return None
3208
+ pair = _tui_forge_plan_and_paths()
3209
+ if pair is None:
3210
+ return None
3211
+ plan, paths = pair
3212
+ # No usable plan yet → let the runner surface the "add tasks first"
3213
+ # guidance rather than offering a launch that will be rejected.
3214
+ tasks = plan.get("tasks") if isinstance(plan, dict) else None
3215
+ reqs = plan.get("requirements") if isinstance(plan, dict) else None
3216
+ if not (isinstance(tasks, list) and tasks) and not (
3217
+ isinstance(reqs, list) and reqs
3218
+ ):
3219
+ return None
3220
+ return {"picker": _tui_forge_launch_gate_picker(plan, paths)}
3221
+
3222
+ _tui_panel_providers = {
3223
+ "/status": _tui_status_panel,
3224
+ "/usage": _tui_usage_panel,
3225
+ "/ctx": _tui_context_panel,
3226
+ "/context": _tui_context_panel,
3227
+ "/model-info": _tui_model_info_panel,
3228
+ "/config": _tui_config_panel,
3229
+ "/toolbar": _tui_toolbar_panel,
3230
+ "/terminals": _tui_terminals_panel,
3231
+ "/skill": _tui_skill_panel,
3232
+ "/forge": _tui_forge_intro_panel,
3233
+ "/show": _tui_forge_show_panel,
3234
+ "/plan": _tui_forge_plan_panel,
3235
+ "/assets": _tui_assets_panel,
3236
+ "/execute": _tui_forge_execute_gate,
3237
+ }
3238
+
3239
+ # Slash-command dropdown: same completer the classic prompt uses, so
3240
+ # typing "/" lists commands and /skill completes registered names.
3241
+ from ..chat_slash_completer import ChatSlashCompleter
3242
+
3243
+ def _tui_skill_names() -> list[str]:
3244
+ built = _tui_box.get("session")
3245
+ return sorted(str(n) for n in (getattr(built, "skill_registry", {}) or {}))
3246
+
3247
+ _tui_completer = ChatSlashCompleter(
3248
+ mode_provider=lambda: _tui_forge_state.ui_mode,
3249
+ skill_names_provider=_tui_skill_names,
3250
+ )
3251
+
3252
+ # TUI-native /mode picker: bare /mode opens a selectable popup;
3253
+ # "/mode <name>" still applies inline via the command runner.
3254
+ from ..commands.chat_terminal import (
3255
+ _chat_mode_display,
3256
+ _chat_mode_rows,
3257
+ _chat_persona_rows,
3258
+ _chat_trace_rows,
3259
+ )
3260
+ from ..commands.startup import (
3261
+ _chat_trace_level,
3262
+ _set_chat_stream_enabled,
3263
+ _set_chat_trace_level,
3264
+ )
3265
+
3266
+ _FULLACCESS_WARNING = (
3267
+ "full (fullaccess) disables write/shell safety guards and approval prompts."
3268
+ )
3269
+
3270
+ def _tui_mode_select(value: str) -> list[tuple[str, str]] | None:
3271
+ built = _tui_box.get("session")
3272
+ if built is None:
3273
+ return None
3274
+ current = str(getattr(built, "mode", "review") or "review").strip().lower()
3275
+ if _chat_plan_mode_enabled(_tui_plan_state):
3276
+ if value == "readonly":
3277
+ return [("system", "Mode already set: Read-Only (Plan Mode is on)")]
3278
+ return [
3279
+ (
3280
+ "warn",
3281
+ "Cannot change execution mode while Plan Mode is on. "
3282
+ "Use /plan off first.",
3283
+ )
3284
+ ]
3285
+ if value == current:
3286
+ msgs = [("system", f"Mode already set: {_chat_mode_display(value)}")]
3287
+ if value == "fullaccess":
3288
+ msgs.append(("warn", _FULLACCESS_WARNING))
3289
+ return msgs
3290
+ try:
3291
+ _clear_persona_restore(built)
3292
+ _apply_chat_effective_mode(
3293
+ session=built, next_mode=value, persist_default_mode=True
3294
+ )
3295
+ except Exception as exc: # noqa: BLE001
3296
+ return [("error", f"Failed to change mode: {exc}")]
3297
+ _tui_state.exec_mode = value
3298
+ msgs = [("system", f"Mode → {_chat_mode_display(value)}")]
3299
+ if value == "fullaccess":
3300
+ msgs.append(("warn", _FULLACCESS_WARNING))
3301
+ return msgs
3302
+
3303
+ def _tui_persona_select(value: str) -> list[tuple[str, str]] | None:
3304
+ built = _tui_box.get("session")
3305
+ if built is None:
3306
+ return None
3307
+ if not persona_modes_enabled(getattr(built, "cfg", None)):
3308
+ return [("warn", "Persona modes are disabled.")]
3309
+ if _chat_plan_mode_enabled(_tui_plan_state):
3310
+ return [
3311
+ (
3312
+ "warn",
3313
+ "Cannot change persona while Plan Mode is on. Use /plan off first.",
3314
+ )
3315
+ ]
3316
+ registry = _session_persona_registry(built)
3317
+ persona_name = normalize_persona(value, registry)
3318
+ current_persona = normalize_persona(getattr(built, "persona", "code"), registry)
3319
+ if persona_name == current_persona:
3320
+ return [("system", f"Persona already set: {persona_name}")]
3321
+ try:
3322
+ _apply_chat_persona(session=built, persona=persona_name, source="user")
3323
+ except Exception as exc: # noqa: BLE001
3324
+ return [("error", f"Failed to change persona: {exc}")]
3325
+ _tui_state.persona = persona_name
3326
+ _tui_state.exec_mode = str(getattr(built, "mode", "") or "").strip()
3327
+ return [("system", f"Persona → {persona_name}")]
3328
+
3329
+ def _tui_persona_picker() -> dict[str, Any] | None:
3330
+ built = _tui_box.get("session")
3331
+ registry = _session_persona_registry(built)
3332
+ current_persona = normalize_persona(getattr(built, "persona", "code"), registry)
3333
+ rows: list[dict[str, Any]] = []
3334
+ for value, label, desc in _chat_persona_rows(custom=registry):
3335
+ clean = label.split(") ", 1)[-1] if ") " in label else label
3336
+ rows.append(
3337
+ {
3338
+ "label": clean,
3339
+ "description": desc,
3340
+ "value": value,
3341
+ "current": value == current_persona,
3342
+ }
3343
+ )
3344
+ return {
3345
+ "title": "Persona",
3346
+ "rows": rows,
3347
+ "on_select": _tui_persona_select,
3348
+ }
3349
+
3350
+ def _tui_persona_cycle() -> list[tuple[str, str]] | None:
3351
+ # Kilo/OpenCode-style shortcut: Tab on an empty input cycles
3352
+ # code -> architect -> ask -> debug -> code. Same primitive,
3353
+ # clamp, and events as /mode <persona>. Success is SILENT:
3354
+ # the footer badge flipping is the feedback — the transcript
3355
+ # only ever sees warnings and errors.
3356
+ built = _tui_box.get("session")
3357
+ if built is None:
3358
+ return None
3359
+ if not persona_modes_enabled(getattr(built, "cfg", None)):
3360
+ return None
3361
+ if _chat_plan_mode_enabled(_tui_plan_state):
3362
+ return [
3363
+ (
3364
+ "warn",
3365
+ "Cannot change persona while Plan Mode is on. Use /plan off first.",
3366
+ )
3367
+ ]
3368
+ target = next_persona(
3369
+ getattr(built, "persona", "code"), _session_persona_registry(built)
3370
+ )
3371
+ try:
3372
+ effective = _apply_chat_persona(
3373
+ session=built, persona=target, source="user"
3374
+ )
3375
+ except Exception as exc: # noqa: BLE001
3376
+ return [("error", f"Failed to change persona: {exc}")]
3377
+ _tui_state.persona = target
3378
+ _tui_state.exec_mode = effective
3379
+ return None
3380
+
3381
+ def _tui_mode_cycle() -> list[tuple[str, str]] | None:
3382
+ # Shift+Tab shortcut: advance the execution mode one notch
3383
+ # (read -> safe -> fast -> full -> read). Routed through
3384
+ # _tui_mode_select so it shares the /mode picker's plan-mode
3385
+ # guard, persistence, and fullaccess warning — the mode is the
3386
+ # only approval authority, so this must not be a footer-only
3387
+ # flip. Unlike the persona cycle this is NOT silent: landing on
3388
+ # fullaccess has to announce itself.
3389
+ built = _tui_box.get("session")
3390
+ if built is None:
3391
+ return None
3392
+ current = str(getattr(built, "mode", "review") or "review").strip().lower()
3393
+ return _tui_mode_select(_next_exec_mode(current))
3394
+
3395
+ def _tui_mode_picker() -> dict[str, Any] | None:
3396
+ built = _tui_box.get("session")
3397
+ current = str(getattr(built, "mode", "review") or "review").strip().lower()
3398
+ rows: list[dict[str, Any]] = []
3399
+ for value, label, desc in _chat_mode_rows():
3400
+ clean = label.split(") ", 1)[-1] if ") " in label else label
3401
+ rows.append(
3402
+ {
3403
+ "label": clean,
3404
+ "description": desc,
3405
+ "value": value,
3406
+ "current": value == current,
3407
+ }
3408
+ )
3409
+ return {"title": "Mode", "rows": rows, "on_select": _tui_mode_select}
3410
+
3411
+ # TUI-native /stream picker: bare /stream opens a selectable popup;
3412
+ # "/stream on|off|status" still applies inline via the command runner.
3413
+ def _tui_stream_select(value: str) -> list[tuple[str, str]] | None:
3414
+ built = _tui_box.get("session")
3415
+ if built is None:
3416
+ return None
3417
+ enabled = value == "on"
3418
+ current = bool(getattr(built, "stream", True))
3419
+ if enabled == current:
3420
+ return [("system", f"Streaming already set: {value}")]
3421
+ _set_chat_stream_enabled(session=built, enabled=enabled)
3422
+ return [("system", f"Streaming → {value}")]
3423
+
3424
+ def _tui_stream_picker() -> dict[str, Any] | None:
3425
+ built = _tui_box.get("session")
3426
+ enabled = bool(getattr(built, "stream", True)) if built is not None else True
3427
+ return {
3428
+ "title": "Streaming",
3429
+ "rows": [
3430
+ {
3431
+ "label": "on",
3432
+ "description": (
3433
+ "Render answers and available safe reasoning summaries live "
3434
+ "(recommended)."
3435
+ ),
3436
+ "value": "on",
3437
+ "current": enabled,
3438
+ },
3439
+ {
3440
+ "label": "off",
3441
+ "description": (
3442
+ "Buffer answers and summaries until each response completes."
3443
+ ),
3444
+ "value": "off",
3445
+ "current": not enabled,
3446
+ },
3447
+ ],
3448
+ "on_select": _tui_stream_select,
3449
+ }
3450
+
3451
+ # TUI-native /trace picker: bare /trace opens a selectable popup;
3452
+ # "/trace <level>" still applies inline via the command runner.
3453
+ def _tui_trace_select(value: str) -> list[tuple[str, str]] | None:
3454
+ built = _tui_box.get("session")
3455
+ if built is None:
3456
+ return None
3457
+ current = _chat_trace_level(built)
3458
+ if value == current:
3459
+ return [("system", f"Reasoning trace already set: {value}")]
3460
+ try:
3461
+ applied = _set_chat_trace_level(session=built, level=value)
3462
+ except Exception as exc: # noqa: BLE001
3463
+ return [("error", f"Failed to set trace level: {exc}")]
3464
+ return [("system", f"Reasoning trace → {applied}")]
3465
+
3466
+ def _tui_trace_picker() -> dict[str, Any] | None:
3467
+ built = _tui_box.get("session")
3468
+ current = _chat_trace_level(built) if built is not None else "compact"
3469
+ rows: list[dict[str, Any]] = []
3470
+ for value, label, desc in _chat_trace_rows():
3471
+ clean = label.split(") ", 1)[-1] if ") " in label else label
3472
+ rows.append(
3473
+ {
3474
+ "label": clean,
3475
+ "description": desc,
3476
+ "value": value,
3477
+ "current": value == current,
3478
+ }
3479
+ )
3480
+ return {
3481
+ "title": "Reasoning Trace",
3482
+ "rows": rows,
3483
+ "on_select": _tui_trace_select,
3484
+ }
3485
+
3486
+ # TUI-native forge pickers: bare /plan opens a tasks/markdown/edit
3487
+ # chooser; bare /assistant toggles the planner assistant. Each row's
3488
+ # on_select SUBMITS the explicit form (e.g. "/plan tasks") so the
3489
+ # panel/runner path applies with no extra Enter. They fire only in a
3490
+ # Forge session; in chat mode they return None and fall through.
3491
+ def _tui_forge_plan_picker() -> dict[str, Any] | None:
3492
+ if _tui_forge_state.ui_mode != "forge":
3493
+ return None
3494
+ rows = [
3495
+ {
3496
+ "label": "tasks",
3497
+ "description": "Show the plan summary — goal, requirements, task table.",
3498
+ "value": "tasks",
3499
+ "current": False,
3500
+ },
3501
+ {
3502
+ "label": "markdown",
3503
+ "description": "Preview PLAN.md for the current run.",
3504
+ "value": "markdown",
3505
+ "current": False,
3506
+ },
3507
+ {
3508
+ "label": "edit",
3509
+ "description": "Edit plan.json in an in-TUI editor, then reload.",
3510
+ "value": "edit",
3511
+ "current": False,
3512
+ },
3513
+ ]
3514
+ return {
3515
+ "title": "Plan",
3516
+ "rows": rows,
3517
+ "on_select": lambda value: {"submit": f"/plan {value}"},
3518
+ "hint": "↑↓ select · Enter · Esc cancel",
3519
+ }
3520
+
3521
+ def _tui_forge_assistant_picker() -> dict[str, Any] | None:
3522
+ if _tui_forge_state.ui_mode != "forge":
3523
+ return None
3524
+ enabled = bool(getattr(_tui_forge_state, "assistant_enabled", False))
3525
+ rows = [
3526
+ {
3527
+ "label": "on",
3528
+ "description": "Planner assistant drafts and refines the plan with you.",
3529
+ "value": "on",
3530
+ "current": enabled,
3531
+ },
3532
+ {
3533
+ "label": "off",
3534
+ "description": "Manual planning only — you drive /goal, /task, /plan edit.",
3535
+ "value": "off",
3536
+ "current": not enabled,
3537
+ },
3538
+ ]
3539
+ return {
3540
+ "title": "Planner Assistant",
3541
+ "rows": rows,
3542
+ "on_select": lambda value: {"submit": f"/assistant {value}"},
3543
+ "hint": "↑↓ select · Enter · Esc cancel",
3544
+ }
3545
+
3546
+ # TUI-native /resume picker: bare /resume opens a selectable list of
3547
+ # previous sessions (label = relative time, description = preview);
3548
+ # choosing one swaps the live session in place and reloads the prior
3549
+ # conversation into the transcript so the user "enters" it. The typed
3550
+ # form "/resume <index|id>" is intercepted in the command runner so it
3551
+ # applies natively too (no flat capture dump).
3552
+ from ...session_store import resolve_sessions_dir
3553
+ from ..commands.chat_resume_helpers import (
3554
+ _chat_resume_picker_spec,
3555
+ _collect_chat_resume_candidates,
3556
+ _resolve_chat_resume_direct_session_id,
3557
+ _resolve_chat_resume_target,
3558
+ _resume_chat_session,
3559
+ )
3560
+
3561
+ def _tui_resume_context() -> tuple[Any, Path, str, Any, Any] | None:
3562
+ # (live session, sessions dir, current session id, current
3563
+ # workspace_root, current git_root) or None when there is no
3564
+ # session / no resolvable sessions directory. The workspace
3565
+ # roots scope the candidate list so /resume only shows this
3566
+ # workspace's chats.
3567
+ built = _tui_box.get("session")
3568
+ if built is None:
3569
+ return None
3570
+ store = getattr(built, "store", None)
3571
+ sessions_dir_raw = getattr(store, "sessions_dir", None)
3572
+ if sessions_dir_raw is not None:
3573
+ sessions_dir = Path(sessions_dir_raw)
3574
+ else:
3575
+ cfg = getattr(built, "cfg", None)
3576
+ if not isinstance(cfg, AppConfig):
3577
+ return None
3578
+ sessions_dir = resolve_sessions_dir(cfg)
3579
+ return (
3580
+ built,
3581
+ sessions_dir,
3582
+ str(getattr(store, "session_id", "") or ""),
3583
+ getattr(store, "workspace_root", None),
3584
+ getattr(store, "git_root", None),
3585
+ )
3586
+
3587
+ def _tui_resume_apply(target_session_id: str) -> None:
3588
+ # Swap the live session to the chosen one (mutated in place, so the
3589
+ # app's session reference + surface stay valid), reload its history
3590
+ # into the transcript, and refresh the footer HUD/model/mode. Writes
3591
+ # the outcome line straight to the transcript (with a role chosen to
3592
+ # stay visible) rather than returning it, so the picker and the typed
3593
+ # paths behave identically.
3594
+ built = _tui_box.get("session")
3595
+ surface = getattr(built, "surface", None) if built is not None else None
3596
+
3597
+ def _note(text: str, role: str = "system") -> None:
3598
+ appender = getattr(surface, "append_note", None)
3599
+ if callable(appender):
3600
+ try:
3601
+ appender(text, role=role)
3602
+ except Exception: # noqa: BLE001 - feedback is best-effort
3603
+ pass
3604
+
3605
+ if built is None:
3606
+ return
3607
+ store = getattr(built, "store", None)
3608
+ current_before = str(getattr(store, "session_id", "") or "")
3609
+ try:
3610
+ resumed, message, history = _resume_chat_session(
3611
+ session=built, target_session_id=str(target_session_id)
3612
+ )
3613
+ except Exception as exc: # noqa: BLE001 - never crash the UI
3614
+ _note(f"Resume failed: {exc}", role="error")
3615
+ return
3616
+ plain = _strip_rich_markup(message)
3617
+ if not resumed:
3618
+ _note(plain or "Resume failed.", role="error")
3619
+ return
3620
+ current_after = str(
3621
+ getattr(getattr(built, "store", None), "session_id", "") or ""
3622
+ )
3623
+ if current_after == current_before:
3624
+ # No-op resume (the chosen session is already active): do NOT
3625
+ # wipe the transcript — just report it. (The picker excludes the
3626
+ # current session, but a typed "/resume <current-id>" can land
3627
+ # here via the direct-id fallback.)
3628
+ _note(plain or "Session already active.", role="system")
3629
+ return
3630
+ # Real swap: reload the prior conversation + refresh HUD/footer.
3631
+ loader = getattr(surface, "replace_history", None)
3632
+ if callable(loader):
3633
+ try:
3634
+ loader(history)
3635
+ except Exception: # noqa: BLE001 - reload is best-effort
3636
+ pass
3637
+ # Restore the resumed session's persona (mode narrowing,
3638
+ # write scope, model role) before the badge sync reads it.
3639
+ try:
3640
+ _reapply_resumed_persona(session=built, console=None)
3641
+ except Exception: # noqa: BLE001 - resume must still land
3642
+ pass
3643
+ try:
3644
+ _tui_on_turn_complete() # refresh tokens/cost/context
3645
+ except Exception:
3646
+ pass
3647
+ _sync_tui_session_state(_tui_state, built, include_exec_mode=True)
3648
+ new_model = str(getattr(getattr(built, "cfg", None), "model", "") or "")
3649
+ if new_model:
3650
+ _tui_state.model_name = new_model
3651
+ # If the reloaded history had nothing visible (e.g. an interrupted
3652
+ # session with only tool turns), the transcript is now empty and the
3653
+ # welcome landing would hide a "system" line — use the assistant role
3654
+ # so the confirmation flips the pane and is actually seen.
3655
+ has_visible = any(
3656
+ isinstance(m, dict)
3657
+ and str(m.get("role") or "").strip().lower() in ("user", "assistant")
3658
+ and isinstance(m.get("content"), str)
3659
+ and m["content"].strip()
3660
+ for m in (history or [])
3661
+ )
3662
+ _note(plain or "Resumed.", role="system" if has_visible else "assistant")
3663
+
3664
+ def _tui_resume_select(value: str) -> None:
3665
+ _tui_resume_apply(str(value))
3666
+ return None
3667
+
3668
+ def _tui_resume_picker() -> dict[str, Any] | None:
3669
+ ctx = _tui_resume_context()
3670
+ if ctx is None:
3671
+ return None
3672
+ _built, sessions_dir, current_session_id, ws_pick, git_pick = ctx
3673
+ try:
3674
+ candidates = _collect_chat_resume_candidates(
3675
+ sessions_dir=sessions_dir,
3676
+ current_session_id=current_session_id,
3677
+ workspace_root=ws_pick,
3678
+ git_root=git_pick,
3679
+ )
3680
+ spec = _chat_resume_picker_spec(sessions=candidates)
3681
+ except Exception: # noqa: BLE001 - never crash the UI on a picker
3682
+ return None
3683
+ if spec is None:
3684
+ return None # nothing to resume → runner prints guidance
3685
+ spec["on_select"] = _tui_resume_select
3686
+ return spec
3687
+
3688
+ _tui_picker_providers = {
3689
+ "/login": _tui_login_picker,
3690
+ "/mode": _tui_mode_picker,
3691
+ "/persona": _tui_persona_picker,
3692
+ "/stream": _tui_stream_picker,
3693
+ "/trace": _tui_trace_picker,
3694
+ "/resume": _tui_resume_picker,
3695
+ "/plan": _tui_forge_plan_picker,
3696
+ "/assistant": _tui_forge_assistant_picker,
3697
+ "/assets": _tui_assets_picker,
3698
+ }
3699
+
3700
+ _tui_ok = False
3701
+ _tui_result: Any = None
3702
+ try:
3703
+ _tui_result, _ = _run_tui(
3704
+ _tui_state,
3705
+ session_builder=None if subscription_blocked else _tui_session_builder,
3706
+ on_turn_complete=None if subscription_blocked else _tui_on_turn_complete,
3707
+ on_hud_refresh=None if subscription_blocked else _tui_on_turn_complete,
3708
+ command_runner=_tui_command_runner,
3709
+ panel_providers=_tui_panel_providers,
3710
+ picker_providers=_tui_picker_providers,
3711
+ persona_cycle=_tui_persona_cycle,
3712
+ mode_cycle=_tui_mode_cycle,
3713
+ completer=_tui_completer,
3714
+ config_flow_factory=_tui_config_flow_factory,
3715
+ on_config_saved=_tui_on_config_saved,
3716
+ unavailable_message=(
3717
+ subscription_availability.message if subscription_blocked else None
3718
+ ),
3719
+ subscription_provider_id=(
3720
+ subscription_availability.provider_id
3721
+ if subscription_blocked
3722
+ and not subscription_availability.selection_required
3723
+ else None
3724
+ ),
3725
+ open_config_on_start=bool(
3726
+ subscription_blocked and subscription_availability.selection_required
3727
+ ),
3728
+ )
3729
+ _tui_ok = True
3730
+ except Exception as _tui_exc: # pragma: no cover - defensive fallback
3731
+ console.print(
3732
+ f"[yellow]TUI unavailable ({_tui_exc}); using classic chat.[/yellow]"
3733
+ )
3734
+ finally:
3735
+ _tui_session = _tui_box.get("session")
3736
+ if _tui_session is not None:
3737
+ try:
3738
+ _tui_session.close()
3739
+ except Exception:
3740
+ pass
3741
+ if _tui_ok:
3742
+ if (
3743
+ isinstance(_tui_result, tuple)
3744
+ and len(_tui_result) == 2
3745
+ and _tui_result[0] == "login_connection"
3746
+ ):
3747
+ from ..commands.auth import login_connection_interactively
3748
+ from ..commands.startup import _run_default_chat_action
3749
+
3750
+ login_connection_interactively(
3751
+ str(_tui_result[1]),
3752
+ console=console,
3753
+ )
3754
+ _run_default_chat_action(
3755
+ path=Path(str(session_root)),
3756
+ allow_broad_workspace=True,
3757
+ mode=mode,
3758
+ model=None,
3759
+ base_url=None,
3760
+ temperature=temperature,
3761
+ stream=stream,
3762
+ max_steps=max_steps,
3763
+ subagents=subagents,
3764
+ no_log=no_log,
3765
+ verify_cmd=verify_cmd,
3766
+ yes=yes,
3767
+ )
3768
+ return
3769
+ # "Switch project" from /config exits with this sentinel; relaunch a
3770
+ # fresh chat bound to the chosen folder (the old session is already
3771
+ # closed above). We forward this session's execution-posture flags
3772
+ # (mode/yes/no_log/verify_cmd/…) so switching project doesn't
3773
+ # silently reset approval policy/mode/logging. NOTE: this re-enters
3774
+ # chat() (one frame per switch); switching is a deliberate,
3775
+ # heavyweight manual action so the depth is bounded in practice.
3776
+ if (
3777
+ isinstance(_tui_result, tuple)
3778
+ and len(_tui_result) == 2
3779
+ and _tui_result[0]
3780
+ in {
3781
+ "switch_workspace",
3782
+ "restart_config",
3783
+ "restart_routing_config",
3784
+ }
3785
+ ):
3786
+ from ..commands.startup import _run_default_chat_action
3787
+
3788
+ connection_restart = _tui_result[0] == "restart_config"
3789
+ _run_default_chat_action(
3790
+ path=Path(str(_tui_result[1])),
3791
+ allow_broad_workspace=True,
3792
+ mode=mode,
3793
+ model=None if connection_restart else model,
3794
+ base_url=None if connection_restart else base_url,
3795
+ temperature=temperature,
3796
+ stream=stream,
3797
+ max_steps=max_steps,
3798
+ subagents=subagents,
3799
+ no_log=no_log,
3800
+ verify_cmd=verify_cmd,
3801
+ yes=yes,
3802
+ )
3803
+ return
3804
+ if subscription_blocked:
3805
+ # The TUI failed after we intentionally deferred session construction.
3806
+ # Do not fall through and create the same guaranteed-to-fail session in
3807
+ # classic chat; preserve the actionable blocker instead.
3808
+ style = "red" if subscription_availability.is_error else "yellow"
3809
+ console.print(f"[{style}]{subscription_availability.message}[/{style}]")
3810
+ raise typer.Exit(code=1)
3811
+ printWelcome(
3812
+ console=console,
3813
+ workspace=focus_path if focus_path != session_root else session_root,
3814
+ model=str(effective.model or "?"),
3815
+ )
3816
+ console.print("[dim]Starting chat session...[/dim]")
3817
+ create_session_kwargs = {
3818
+ "cfg": effective,
3819
+ "root": session_root,
3820
+ "mode": effective_mode,
3821
+ "runtime_kind": RuntimeKind.INTERACTIVE_CHAT,
3822
+ "yes": yes,
3823
+ "max_steps": effective.max_steps,
3824
+ "no_log": no_log,
3825
+ "api_key_override": api_key_override,
3826
+ "console": console,
3827
+ "surface": _make_rich_surface(console=console, show_status_line=False),
3828
+ "non_interactive": non_interactive,
3829
+ "enable_chat_turn_step_budget": True,
3830
+ "chat_turn_fixed_override": (effective.max_steps if max_steps_provided else None),
3831
+ "verify_cmd": verify_cmd,
3832
+ "subagents_enabled": effective.subagents_enabled,
3833
+ "workspace_binding": workspace_binding,
3834
+ "crash_diagnostic_log_path": diagnostic_log,
3835
+ }
3836
+ try:
3837
+ session = create_session(**create_session_kwargs)
3838
+ except ConfigError as e:
3839
+ if not _is_default_shell_sandbox_startup_failure(cfg=effective, error=e):
3840
+ raise
3841
+ console.print(
3842
+ "[yellow]Shell sandbox unavailable:[/yellow] starting chat with shell execution disabled. "
3843
+ "Run `alysis doctor sandbox` for setup help, or set "
3844
+ "ALYSIS_SHELL_SANDBOX_MODE=off for explicit unsafe host execution."
3845
+ )
3846
+ create_session_kwargs["cfg"] = _cfg_with_warn_shell_sandbox_mode(effective)
3847
+ session = create_session(**create_session_kwargs)
3848
+ _set_chat_usage_hud_enabled(session, _resolve_usage_hud_default(effective))
3849
+ baseline_temperature = getattr(getattr(session, "client", None), "temperature", None)
3850
+ if baseline_temperature is None:
3851
+ baseline_temperature = getattr(effective, "chat_temperature", None)
3852
+ session._toolbar_default_temperature = baseline_temperature
3853
+ _refresh_chat_hud_context_cache(session)
3854
+ _ensure_session_summary_metadata(session=session, allow_model_summary=False)
3855
+ except ConfigError as e:
3856
+ console.print(f"[red]Config error:[/red] {e}")
3857
+ raise typer.Exit(code=2) from e
3858
+ except WorkspaceBindingError as e:
3859
+ console.print(f"[red]Workspace error:[/red] {e}")
3860
+ raise typer.Exit(code=1) from e
3861
+ try:
3862
+ if not bool(getattr(session, "subagents_enabled", False)):
3863
+ console.print(
3864
+ "[dim]Tip: enable subagents with "
3865
+ "`alysis config set subagents_enabled true`, then start a new session.[/dim]"
3866
+ )
3867
+ pending_images = [os.fspath(p) for p in (image or [])]
3868
+ forge_state = _ForgeChatState()
3869
+ plan_mode_state = _ChatPlanModeState()
3870
+ _apply_startup_persona(session=session, console=console)
3871
+ prompt_session = _maybe_make_chat_prompt_session(
3872
+ console=console,
3873
+ root=focus_path,
3874
+ pending_images=pending_images,
3875
+ forge_state=forge_state,
3876
+ session=session,
3877
+ plan_mode_state=plan_mode_state,
3878
+ )
3879
+ if pending_images:
3880
+ console.print(f"Queued {len(pending_images)} image(s) for your next message.")
3881
+ while True:
3882
+ try:
3883
+ if prompt_session:
3884
+ prompt_session_erases = bool(
3885
+ getattr(prompt_session, "_alysis_erase_when_done", True)
3886
+ )
3887
+
3888
+ def _bottom_toolbar(
3889
+ _pending_images: list[str] = pending_images,
3890
+ ) -> str:
3891
+ return _chat_bottom_toolbar(
3892
+ session=session,
3893
+ pending_images=_pending_images,
3894
+ forge_state=forge_state,
3895
+ plan_mode_enabled=_chat_plan_mode_enabled(plan_mode_state),
3896
+ )
3897
+
3898
+ prompt_result = prompt_session.prompt(
3899
+ _chat_prompt_label_formatted(
3900
+ ui_mode=forge_state.ui_mode,
3901
+ mode=str(getattr(session, "mode", "")),
3902
+ ),
3903
+ bottom_toolbar=_bottom_toolbar,
3904
+ )
3905
+ if prompt_result is _CHAT_PROMPT_RESULT_PLAN_MODE_OFF:
3906
+ user_msg = "/plan off"
3907
+ else:
3908
+ user_msg = prompt_result
3909
+ if not prompt_session_erases and isinstance(user_msg, str):
3910
+ _clear_submitted_prompt_line(
3911
+ submitted_text=user_msg,
3912
+ prompt_label=_chat_prompt_label(
3913
+ ui_mode=forge_state.ui_mode,
3914
+ mode=str(getattr(session, "mode", "")),
3915
+ ),
3916
+ )
3917
+ else:
3918
+ fallback_label = _chat_prompt_fallback_label(
3919
+ ui_mode=forge_state.ui_mode,
3920
+ mode=str(getattr(session, "mode", "")),
3921
+ )
3922
+ user_msg = typer.prompt(fallback_label, prompt_suffix=" ")
3923
+ _clear_submitted_prompt_line(
3924
+ submitted_text=user_msg,
3925
+ prompt_label=f"{fallback_label} ",
3926
+ )
3927
+ except (EOFError, KeyboardInterrupt):
3928
+ console.print("")
3929
+ return
3930
+
3931
+ command_result = _handle_chat_command(
3932
+ input_text=user_msg,
3933
+ root=focus_path,
3934
+ session=session,
3935
+ pending_images=pending_images,
3936
+ console=console,
3937
+ forge_state=forge_state,
3938
+ plan_mode_state=plan_mode_state,
3939
+ plan_mode_escape_supported=prompt_session is not None,
3940
+ )
3941
+ if command_result == "exit":
3942
+ return
3943
+ if command_result == "handled":
3944
+ continue
3945
+ if command_result == "send" and _chat_plan_mode_enabled(plan_mode_state):
3946
+ command_result = _resolve_interactive_plan_mode_request(
3947
+ session=session,
3948
+ console=console,
3949
+ plan_mode_state=plan_mode_state,
3950
+ user_message=user_msg,
3951
+ plan_mode_escape_supported=prompt_session is not None,
3952
+ )
3953
+ if command_result == "handled":
3954
+ continue
3955
+
3956
+ execution_instruction = (
3957
+ command_result.instruction
3958
+ if isinstance(command_result, _ChatExecutionRequest)
3959
+ else user_msg
3960
+ )
3961
+ routing_mode_override = (
3962
+ command_result.routing_mode_override
3963
+ if isinstance(command_result, _ChatExecutionRequest)
3964
+ else None
3965
+ )
3966
+ ephemeral_system_messages = (
3967
+ list(command_result.ephemeral_system_messages)
3968
+ if isinstance(command_result, _ChatExecutionRequest)
3969
+ and command_result.ephemeral_system_messages
3970
+ else None
3971
+ )
3972
+ ephemeral_user_messages = (
3973
+ list(command_result.ephemeral_user_messages)
3974
+ if isinstance(command_result, _ChatExecutionRequest)
3975
+ and command_result.ephemeral_user_messages
3976
+ else None
3977
+ )
3978
+ temporary_mode_override = (
3979
+ command_result.mode_override
3980
+ if isinstance(command_result, _ChatExecutionRequest)
3981
+ else None
3982
+ )
3983
+ restore_mode_after = (
3984
+ command_result.restore_mode_after
3985
+ if isinstance(command_result, _ChatExecutionRequest)
3986
+ else None
3987
+ )
3988
+ plan_mode_capture_task = (
3989
+ command_result.plan_mode_capture_task
3990
+ if isinstance(command_result, _ChatExecutionRequest)
3991
+ else None
3992
+ )
3993
+ chat_only_turn = (
3994
+ bool(command_result.chat_only)
3995
+ if isinstance(command_result, _ChatExecutionRequest)
3996
+ else False
3997
+ )
3998
+
3999
+ images_for_turn = pending_images.copy()
4000
+ interrupted = False
4001
+ llm_failed = False
4002
+ restored_mode_after_turn = False
4003
+ turn_start_messages = (
4004
+ len(getattr(session, "messages", []) or [])
4005
+ if plan_mode_capture_task is not None
4006
+ else 0
4007
+ )
4008
+ if temporary_mode_override is not None:
4009
+ try:
4010
+ _apply_chat_effective_mode(
4011
+ session=session,
4012
+ next_mode=temporary_mode_override,
4013
+ persist_default_mode=False,
4014
+ )
4015
+ except Exception as e: # noqa: BLE001
4016
+ console.print(f"[red]Failed to prepare approved execution:[/red] {e}")
4017
+ continue
4018
+ try:
4019
+ with _chat_turn_interrupt_monitor():
4020
+ run_turn_kwargs: dict[str, Any] = {"image_paths": images_for_turn or None}
4021
+ if routing_mode_override is not None:
4022
+ run_turn_kwargs["routing_mode_override"] = routing_mode_override
4023
+ combined_ephemeral_system = list(ephemeral_system_messages or [])
4024
+ combined_ephemeral_system += persona_overlay_messages(
4025
+ cfg=getattr(session, "cfg", None),
4026
+ persona=getattr(session, "persona", "code"),
4027
+ registry=_session_persona_registry(session),
4028
+ )
4029
+ if combined_ephemeral_system:
4030
+ run_turn_kwargs["ephemeral_system_messages"] = combined_ephemeral_system
4031
+ combined_ephemeral_user = persona_overlay_user_messages(
4032
+ cfg=getattr(session, "cfg", None),
4033
+ persona=getattr(session, "persona", "code"),
4034
+ registry=_session_persona_registry(session),
4035
+ )
4036
+ combined_ephemeral_user += list(ephemeral_user_messages or [])
4037
+ if combined_ephemeral_user:
4038
+ run_turn_kwargs["ephemeral_user_messages"] = combined_ephemeral_user
4039
+ if chat_only_turn:
4040
+ run_turn_kwargs["chat_only"] = True
4041
+ session.run_turn(execution_instruction, **run_turn_kwargs)
4042
+ except KeyboardInterrupt:
4043
+ interrupted = True
4044
+ _finish_chat_surface_activity(session=session)
4045
+ console.print(
4046
+ "[yellow]Interrupted current turn.[/yellow] "
4047
+ "You can send a new message or use /exit."
4048
+ )
4049
+ except LLMError as e:
4050
+ llm_failed = True
4051
+ _render_chat_llm_error(session=session, console=console, error=e)
4052
+ finally:
4053
+ if restore_mode_after is not None:
4054
+ try:
4055
+ _apply_chat_effective_mode(
4056
+ session=session,
4057
+ next_mode=restore_mode_after,
4058
+ persist_default_mode=False,
4059
+ )
4060
+ restored_mode_after_turn = True
4061
+ except Exception as e: # noqa: BLE001
4062
+ console.print(
4063
+ f"[red]Failed to restore Plan Mode after execution:[/red] {e}"
4064
+ )
4065
+ # An approved switch_mode proposal applies after mode-override
4066
+ # restoration so the persona's clamp works from the user's
4067
+ # real base mode, never a temporary /ask override.
4068
+ _apply_pending_persona_switch(session=session, console=console)
4069
+
4070
+ if llm_failed:
4071
+ # Keep queued images so the user can retry without re-attaching.
4072
+ pending_images = images_for_turn
4073
+ _refresh_chat_hud_context_cache(session)
4074
+ continue
4075
+
4076
+ pending_images.clear()
4077
+ if restored_mode_after_turn:
4078
+ _refresh_chat_hud_context_cache(session)
4079
+ _refresh_chat_hud_context_cache(session)
4080
+ if interrupted:
4081
+ continue
4082
+ if plan_mode_capture_task is not None:
4083
+ _record_plan_mode_draft_from_turn(
4084
+ session=session,
4085
+ console=console,
4086
+ plan_mode_state=plan_mode_state,
4087
+ user_message=plan_mode_capture_task,
4088
+ start_index=turn_start_messages,
4089
+ )
4090
+ _ensure_session_summary_metadata(session=session, allow_model_summary=False)
4091
+ usage_result = _chat_turn_usage_line(session)
4092
+ if usage_result is not None:
4093
+ usage_line, usage_warning_line = usage_result
4094
+ if usage_line:
4095
+ console.print(usage_line, style="dim", highlight=False)
4096
+ if usage_warning_line:
4097
+ console.print(
4098
+ usage_warning_line,
4099
+ style=_chat_turn_usage_style(session),
4100
+ highlight=False,
4101
+ )
4102
+ finally:
4103
+ session.close()
4104
+
4105
+
4106
+ def run(
4107
+ instruction: str = typer.Argument(..., help="What you want the agent to do."),
4108
+ path: Path = typer.Option(Path("."), "--path", help="Working directory/root."),
4109
+ create_path: bool = typer.Option(
4110
+ False,
4111
+ "--create-path",
4112
+ help="Create --path if it does not exist before binding the workspace.",
4113
+ ),
4114
+ allow_broad_workspace: bool = typer.Option(
4115
+ False,
4116
+ "--allow-broad-workspace",
4117
+ help="Allow guarded broad workspaces in non-interactive startup flows.",
4118
+ ),
4119
+ image: list[Path] | None = typer.Option(
4120
+ None,
4121
+ "--image",
4122
+ help="Attach image path(s). Repeat --image for multiple files.",
4123
+ ),
4124
+ mode: Mode | None = typer.Option(None, "--mode", help="Mode override."),
4125
+ persona: str | None = typer.Option(
4126
+ None,
4127
+ "--persona",
4128
+ help=(
4129
+ "Persona for this one-shot run: code, architect, ask, debug, or a "
4130
+ "custom persona from .alysis_personas."
4131
+ ),
4132
+ ),
4133
+ model: str | None = typer.Option(None, "--model", help="Model override."),
4134
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
4135
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
4136
+ stream: bool | None = typer.Option(
4137
+ None,
4138
+ "--stream/--no-stream",
4139
+ help="Enable streamed assistant output.",
4140
+ ),
4141
+ max_steps: int | None = typer.Option(
4142
+ None,
4143
+ "--max-steps",
4144
+ help="Optional safety limit on agent iterations.",
4145
+ ),
4146
+ subagents: bool | None = typer.Option(
4147
+ None,
4148
+ "--subagents/--no-subagents",
4149
+ help="Enable or disable subagent delegation for this session.",
4150
+ ),
4151
+ no_log: bool = typer.Option(False, "--no-log", help="Disable JSONL session logging."),
4152
+ verify_cmd: list[str] | None = typer.Option(
4153
+ None,
4154
+ "--verify-cmd",
4155
+ help="Override verification command for this run (repeatable).",
4156
+ ),
4157
+ api_key_env: str | None = typer.Option(
4158
+ None,
4159
+ "--api-key-env",
4160
+ help=(
4161
+ "Read API key from this environment variable (overrides ALYSIS_API_KEY/OPENAI_API_KEY)."
4162
+ ),
4163
+ ),
4164
+ api_key_stdin: bool = typer.Option(
4165
+ False,
4166
+ "--api-key-stdin",
4167
+ help="Prompt for API key (hidden input). Key is kept in memory for this run only.",
4168
+ ),
4169
+ api_key: str | None = typer.Option(
4170
+ None,
4171
+ "--api-key",
4172
+ help=(
4173
+ "UNSAFE: Provide API key via CLI argument (may leak via shell history / process list). "
4174
+ "Prefer --api-key-stdin or --api-key-env."
4175
+ ),
4176
+ ),
4177
+ yes: bool = typer.Option(
4178
+ False,
4179
+ "--yes",
4180
+ help="In auto mode, skip confirmations for sensitive commands (hard blocks still apply).",
4181
+ ),
4182
+ benchmark: bool = typer.Option(
4183
+ False,
4184
+ "--benchmark",
4185
+ help=(
4186
+ "Use the raw benchmark/autonomy run profile: auto mode, code-only routing, "
4187
+ "longer fixed step budget, and no subagents/skills/custom tools/web by default."
4188
+ ),
4189
+ ),
4190
+ deadline_seconds: float | None = typer.Option(
4191
+ None,
4192
+ "--deadline-seconds",
4193
+ help=(
4194
+ "Stop this one-shot run after the given invocation-wide wall-clock seconds. "
4195
+ "Defaults to 3600 (60 min); use --no-deadline to run unbounded."
4196
+ ),
4197
+ ),
4198
+ no_deadline: bool = typer.Option(
4199
+ False,
4200
+ "--no-deadline",
4201
+ help="Run without any wall-clock budget, overriding the default one-shot deadline.",
4202
+ ),
4203
+ require_deadline: bool = typer.Option(
4204
+ False,
4205
+ "--require-deadline",
4206
+ help=(
4207
+ "Require a finite one-shot run deadline from CLI, environment, or config. "
4208
+ "Intended for managed hosts."
4209
+ ),
4210
+ ),
4211
+ diagnostic_log: Path | None = typer.Option(
4212
+ None,
4213
+ "--diagnostic-log",
4214
+ help="Append minimal crash-safe diagnostic events to this JSONL path.",
4215
+ ),
4216
+ cli_ctx: Any = None,
4217
+ ) -> None:
4218
+ console = _console()
4219
+ cfg = load_config()
4220
+ current_ctx = cli_ctx if cli_ctx is not None else get_current_context(silent=True)
4221
+ non_interactive = _is_non_interactive_terminal()
4222
+ path_source = current_ctx.get_parameter_source("path") if current_ctx is not None else None
4223
+ max_steps_source = (
4224
+ current_ctx.get_parameter_source("max_steps") if current_ctx is not None else None
4225
+ )
4226
+ max_steps_provided = max_steps is not None
4227
+ if current_ctx is not None:
4228
+ max_steps_provided = (
4229
+ max_steps is not None
4230
+ and max_steps_source is not None
4231
+ and max_steps_source is not ParameterSource.DEFAULT
4232
+ )
4233
+
4234
+ effective = clone_cfg(cfg)
4235
+ raw_benchmark_profile = _raw_benchmark_profile_requested(benchmark=benchmark)
4236
+ if raw_benchmark_profile:
4237
+ _apply_raw_benchmark_profile(effective)
4238
+ if base_url is not None:
4239
+ effective.base_url = base_url
4240
+ if model is not None:
4241
+ effective.model = model
4242
+ if temperature is not None:
4243
+ _apply_temperature_override(effective, temperature)
4244
+ if stream is not None:
4245
+ effective.stream = stream
4246
+ else:
4247
+ # Interactive chat is more readable with incremental output by default.
4248
+ effective.stream = True
4249
+ if max_steps is not None:
4250
+ effective.max_steps = max_steps
4251
+ if subagents is not None:
4252
+ effective.subagents_enabled = subagents
4253
+
4254
+ effective_mode = (mode.value if mode else effective.default_mode) or "review"
4255
+ effective_yes = bool(yes or raw_benchmark_profile)
4256
+ fixed_step_override = (
4257
+ effective.max_steps if (max_steps_provided or raw_benchmark_profile) else None
4258
+ )
4259
+ delegated_execution = effective.execution.backend == "delegated"
4260
+ if delegated_execution and mode is None and str(effective_mode).strip().lower() == "fullaccess":
4261
+ effective_mode = "review"
4262
+ console.print(
4263
+ "[yellow]Delegated runtimes do not inherit the native fullaccess default; "
4264
+ "using review (read-only). Pass --mode auto to allow workspace writes.[/yellow]"
4265
+ )
4266
+ binding_source = _path_binding_source(path_source, path)
4267
+
4268
+ try:
4269
+ if not delegated_execution and not effective.model:
4270
+ raise ConfigError("Model is not set. Run: alysis config set model <MODEL>")
4271
+ api_key_override = None
4272
+ if delegated_execution:
4273
+ from ...agent_runtimes.host import (
4274
+ prepare_delegated_runtime,
4275
+ run_delegated_once,
4276
+ validate_delegated_cli_options,
4277
+ )
4278
+
4279
+ validate_delegated_cli_options(
4280
+ base_url=base_url,
4281
+ temperature=temperature,
4282
+ max_steps=max_steps,
4283
+ subagents=subagents,
4284
+ verify_cmd=verify_cmd,
4285
+ api_key_env=api_key_env,
4286
+ api_key_stdin=api_key_stdin,
4287
+ api_key=api_key,
4288
+ stream=stream,
4289
+ yes=yes,
4290
+ benchmark=raw_benchmark_profile,
4291
+ diagnostic_log=diagnostic_log,
4292
+ )
4293
+ else:
4294
+ api_key_override = _resolve_api_key_override(
4295
+ api_key=api_key,
4296
+ api_key_env=api_key_env,
4297
+ api_key_stdin=api_key_stdin,
4298
+ )
4299
+ workspace_binding = _resolve_startup_workspace_binding(
4300
+ requested_path=path,
4301
+ console=console,
4302
+ interactive=not non_interactive,
4303
+ create_if_missing=create_path,
4304
+ allow_broad_workspace=allow_broad_workspace,
4305
+ source=binding_source,
4306
+ action=WorkspaceAction.CHAT,
4307
+ )
4308
+ run_persona_overlays: list[str] | None = None
4309
+ run_persona_user_overlays: list[str] | None = None
4310
+ run_persona_globs: list[str] | None = None
4311
+ if persona is not None:
4312
+ from ...config import resolve_role_temperature
4313
+ from ...model_router import resolve_model_for_role
4314
+ from ...personas import (
4315
+ load_custom_personas,
4316
+ )
4317
+ from ...personas import (
4318
+ persona_overlay_messages as _persona_overlays_fn,
4319
+ )
4320
+ from ...personas import (
4321
+ persona_overlay_user_messages as _persona_user_overlays_fn,
4322
+ )
4323
+ from ...personas import (
4324
+ resolve_persona_model_role as _resolve_persona_role,
4325
+ )
4326
+
4327
+ normalized_run_persona = str(persona).strip().lower()
4328
+ if delegated_execution:
4329
+ console.print("[red]--persona is not supported with delegated execution.[/red]")
4330
+ raise typer.Exit(code=2)
4331
+ if not persona_modes_enabled(effective):
4332
+ console.print("[red]Persona modes are disabled.[/red]")
4333
+ raise typer.Exit(code=2)
4334
+ run_registry, run_persona_warnings = load_custom_personas(
4335
+ workspace_binding.workspace_context.workspace_root
4336
+ )
4337
+ for warning in run_persona_warnings:
4338
+ console.print(f"[yellow]Custom persona:[/yellow] {warning}")
4339
+ if not is_persona_name(normalized_run_persona, run_registry):
4340
+ console.print(f"[red]Unknown persona:[/red] {normalized_run_persona}")
4341
+ raise typer.Exit(code=2)
4342
+ run_definition = get_persona(normalized_run_persona, run_registry)
4343
+ # Same clamp as interactive switching: the persona may lower the
4344
+ # requested mode, never raise it.
4345
+ effective_mode = resolve_persona_exec_mode(run_definition, effective_mode)
4346
+ if run_definition.allow_write_globs:
4347
+ run_persona_globs = list(run_definition.allow_write_globs)
4348
+ run_persona_overlays = (
4349
+ _persona_overlays_fn(
4350
+ cfg=effective, persona=normalized_run_persona, registry=run_registry
4351
+ )
4352
+ or None
4353
+ )
4354
+ run_persona_user_overlays = (
4355
+ _persona_user_overlays_fn(
4356
+ cfg=effective, persona=normalized_run_persona, registry=run_registry
4357
+ )
4358
+ or None
4359
+ )
4360
+ if model is None:
4361
+ persona_role = _resolve_persona_role(
4362
+ effective, normalized_run_persona, run_registry
4363
+ )
4364
+ resolved_persona_model = str(
4365
+ resolve_model_for_role(cfg=effective, role=persona_role, plan=None) or ""
4366
+ ).strip()
4367
+ if resolved_persona_model and resolved_persona_model != effective.model:
4368
+ effective.model = resolved_persona_model
4369
+ else:
4370
+ persona_role = _resolve_persona_role(
4371
+ effective, normalized_run_persona, run_registry
4372
+ )
4373
+ # create_session constructs the one-shot main client through the
4374
+ # coding slot. Carry the selected persona role's temperature into
4375
+ # that slot so explicit zero values and per-role tuning survive.
4376
+ effective.coding_temperature = resolve_role_temperature(
4377
+ effective,
4378
+ role=persona_role,
4379
+ )
4380
+ effective.default_persona = normalized_run_persona
4381
+ if delegated_execution:
4382
+ from ...config import resolve_run_deadline
4383
+ from ...execution_deadline import DEFAULT_RUN_DEADLINE_SECONDS
4384
+
4385
+ delegated_default_deadline = (
4386
+ None if (require_deadline or no_deadline) else DEFAULT_RUN_DEADLINE_SECONDS
4387
+ )
4388
+ resolved_deadline = resolve_run_deadline(
4389
+ effective,
4390
+ cli_deadline_seconds=deadline_seconds,
4391
+ cli_no_deadline=no_deadline,
4392
+ default_seconds=delegated_default_deadline,
4393
+ )
4394
+ delegated_deadline = resolved_deadline.seconds
4395
+ if require_deadline and delegated_deadline is None:
4396
+ raise ConfigError(
4397
+ "Managed-host run requires a finite run deadline. Pass --deadline-seconds, "
4398
+ "set ALYSIS_RUN_DEADLINE_SECONDS, or configure run_deadline_seconds."
4399
+ )
4400
+ prepare_delegated_runtime(
4401
+ effective,
4402
+ model=model,
4403
+ deadline_seconds=delegated_deadline,
4404
+ )
4405
+ code = run_delegated_once(
4406
+ cfg=effective,
4407
+ cwd=workspace_binding.workspace_context.workspace_root,
4408
+ instruction=instruction,
4409
+ mode=effective_mode,
4410
+ image_paths=tuple(path.resolve() for path in (image or ())),
4411
+ no_log=no_log,
4412
+ console=console,
4413
+ )
4414
+ else:
4415
+ code = run_agent(
4416
+ cfg=effective,
4417
+ root=workspace_binding.workspace_context.workspace_root,
4418
+ instruction=instruction,
4419
+ image_paths=[os.fspath(p) for p in (image or [])],
4420
+ mode=effective_mode,
4421
+ persona_allow_write_globs=run_persona_globs,
4422
+ ephemeral_system_messages=run_persona_overlays,
4423
+ ephemeral_user_messages=run_persona_user_overlays,
4424
+ runtime_kind=RuntimeKind.ONE_SHOT,
4425
+ yes=effective_yes,
4426
+ max_steps=effective.max_steps,
4427
+ no_log=no_log,
4428
+ api_key_override=api_key_override,
4429
+ console=console,
4430
+ surface=_make_rich_surface(console=console),
4431
+ non_interactive=non_interactive,
4432
+ usage_role="run",
4433
+ subagents_enabled=effective.subagents_enabled,
4434
+ one_shot_execution=True,
4435
+ enable_chat_turn_step_budget=True,
4436
+ chat_turn_fixed_override=fixed_step_override,
4437
+ enable_tool_output_offload=(True if raw_benchmark_profile else None),
4438
+ compaction_profile="execution",
4439
+ verify_cmd=verify_cmd,
4440
+ workspace_binding=workspace_binding,
4441
+ run_deadline_seconds=deadline_seconds,
4442
+ no_run_deadline=no_deadline,
4443
+ require_run_deadline=require_deadline,
4444
+ crash_diagnostic_log_path=diagnostic_log,
4445
+ )
4446
+ except ConfigError as e:
4447
+ console.print(f"[red]Config error:[/red] {e}")
4448
+ raise typer.Exit(code=2) from e
4449
+ except WorkspaceBindingError as e:
4450
+ console.print(f"[red]Workspace error:[/red] {e}")
4451
+ raise typer.Exit(code=1) from e
4452
+ except Exception as e: # noqa: BLE001
4453
+ # Prefer friendly Alysis Code MiMo trial copy (trial_expired, rate-limit, ...)
4454
+ # over a raw ``LLM error 402: {...}`` dump; any other error renders as-is.
4455
+ message = sanitize_error_text_for_output(e)
4456
+ try:
4457
+ from ...llm.openai_compat import alysis_trial_error_message
4458
+
4459
+ message = sanitize_error_text_for_output(alysis_trial_error_message(e) or message)
4460
+ except Exception: # noqa: BLE001
4461
+ pass
4462
+ exit_code = exit_code_for_failure(e)
4463
+ label = (
4464
+ "Infrastructure error after retries"
4465
+ if exit_code == INFRASTRUCTURE_FAILURE_EXIT_CODE
4466
+ else "Error"
4467
+ )
4468
+ try:
4469
+ console.print(f"[red]{label}:[/red] {message}")
4470
+ except Exception as render_exc: # noqa: BLE001 - CLI error rendering must not double-crash.
4471
+ safe_plain_error(
4472
+ stream=getattr(console, "file", None),
4473
+ error_type=type(render_exc).__name__,
4474
+ message=sanitize_error_text_for_output(e),
4475
+ )
4476
+ raise typer.Exit(code=exit_code) from e
4477
+
4478
+ raise typer.Exit(code=code)
4479
+
4480
+
4481
+ def _handle_chat_command_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4482
+ _sync_cli_globals(cli_mod)
4483
+ return _handle_chat_command(*args, **kwargs)
4484
+
4485
+
4486
+ def _handle_forge_chat_command_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4487
+ _sync_cli_globals(cli_mod)
4488
+ return _handle_forge_chat_command(*args, **kwargs)
4489
+
4490
+
4491
+ def _run_plan_mode_approval_loop_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4492
+ _sync_cli_globals(cli_mod)
4493
+ return _run_plan_mode_approval_loop(*args, **kwargs)
4494
+
4495
+
4496
+ def _print_chat_context_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4497
+ _sync_cli_globals(cli_mod)
4498
+ return _print_chat_context(*args, **kwargs)
4499
+
4500
+
4501
+ def chat_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4502
+ _sync_cli_globals(cli_mod)
4503
+ return chat(*args, **kwargs)
4504
+
4505
+
4506
+ def run_impl(cli_mod: Any, *args: Any, **kwargs: Any) -> Any:
4507
+ _sync_cli_globals(cli_mod)
4508
+ return run(*args, **kwargs)