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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- 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)
|