aru-code 0.54.0__tar.gz → 0.55.0__tar.gz
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.
- {aru_code-0.54.0/aru_code.egg-info → aru_code-0.55.0}/PKG-INFO +1 -1
- aru_code-0.55.0/aru/__init__.py +1 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/permissions.py +27 -3
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/runtime.py +95 -0
- aru_code-0.55.0/aru/tools/_shared.py +145 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/apply_patch.py +83 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/lsp.py +29 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/registry.py +10 -1
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/ui.py +65 -23
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/thinking.py +31 -4
- {aru_code-0.54.0 → aru_code-0.55.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/pyproject.toml +1 -1
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_apply_patch.py +133 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_lsp_rename.py +54 -0
- aru_code-0.55.0/tests/test_permission_timeout_suspension.py +171 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_permission_flow.py +59 -0
- aru_code-0.54.0/aru/__init__.py +0 -1
- aru_code-0.54.0/aru/tools/_shared.py +0 -94
- {aru_code-0.54.0 → aru_code-0.55.0}/LICENSE +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/README.md +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/analyze_trace.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/_debug/loop_tracer.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/agent_factory.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/base.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/agents/planner.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/cache_patch.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/checkpoints.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/cli.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/commands.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/completers.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/config.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/context.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/display.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/doom_loop.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/events.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/manager.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/format/runner.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/history_blocks.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/client.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/loader.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/memory/store.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/providers.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/runner.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/select.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/session.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/sinks.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/streaming.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tool_policy.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/search.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/shell.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/skill.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/web.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/app.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/themes.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_area.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru/ui.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/setup.cfg +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_catalog.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_codebase.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_config.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_context.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_delegate.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_format.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_lsp.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_main.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_memory.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_permissions.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_plugins.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_providers.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_ranker.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_runtime.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_select.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_worktree.py +0 -0
- {aru_code-0.54.0 → aru_code-0.55.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.55.0"
|
|
@@ -27,10 +27,31 @@ from rich.console import Group
|
|
|
27
27
|
from rich.panel import Panel
|
|
28
28
|
from rich.text import Text
|
|
29
29
|
|
|
30
|
-
from aru.runtime import get_ctx
|
|
30
|
+
from aru.runtime import begin_permission_wait, end_permission_wait, get_ctx
|
|
31
31
|
from aru.select import select_option
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
@contextmanager
|
|
35
|
+
def _permission_prompt_scope(ctx):
|
|
36
|
+
"""Hold ``permission_lock`` while marking the tool-call permission gate.
|
|
37
|
+
|
|
38
|
+
The gate tells the surrounding ``_thread_tool`` wrapper to suspend its
|
|
39
|
+
execution-timeout for as long as we are blocked here — acquiring the lock
|
|
40
|
+
can wait on a sibling tool's open prompt, and the prompt itself waits on
|
|
41
|
+
the user. Without this, the tool could report a timeout mid-prompt and
|
|
42
|
+
then apply the mutation out-of-band once the user finally answered.
|
|
43
|
+
``begin_permission_wait`` runs BEFORE the lock is acquired so the
|
|
44
|
+
lock-wait is covered too; it is a no-op when no gate is installed (async
|
|
45
|
+
tools, tests). See ``aru.runtime.PermissionWaitGate``.
|
|
46
|
+
"""
|
|
47
|
+
begin_permission_wait()
|
|
48
|
+
try:
|
|
49
|
+
with ctx.permission_lock:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
end_permission_wait()
|
|
53
|
+
|
|
54
|
+
|
|
34
55
|
def _resolve_ui(ctx):
|
|
35
56
|
"""Return ``ctx.ui`` or install a ``ReplUI`` on-the-fly.
|
|
36
57
|
|
|
@@ -891,8 +912,11 @@ def check_permission(
|
|
|
891
912
|
except Exception:
|
|
892
913
|
pass # never let plugin errors block permissions
|
|
893
914
|
|
|
894
|
-
# action == "ask" -> prompt user
|
|
895
|
-
|
|
915
|
+
# action == "ask" -> prompt user. The scope holds permission_lock AND
|
|
916
|
+
# suspends the tool-execution timeout while we block on the user (see
|
|
917
|
+
# _permission_prompt_scope) — so a slow human decision can never let the
|
|
918
|
+
# tool time out and then apply the mutation out-of-band.
|
|
919
|
+
with _permission_prompt_scope(ctx):
|
|
896
920
|
# Re-check after acquiring lock (another thread may have resolved it)
|
|
897
921
|
results2 = _resolve_many(category, subjects)
|
|
898
922
|
if any(action == "deny" for action, _ in results2):
|
|
@@ -345,6 +345,101 @@ def is_aborted() -> bool:
|
|
|
345
345
|
return False
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
# ── Permission-wait gate (tool-timeout suspension) ───────────────────
|
|
349
|
+
#
|
|
350
|
+
# Safety-critical. A tool's execution timeout (see
|
|
351
|
+
# ``aru.tools._shared._thread_tool``) must NOT count the time a human spends
|
|
352
|
+
# deciding on a permission prompt. The danger is concrete: if the timeout
|
|
353
|
+
# fired while a prompt was still open, ``asyncio`` reports a timeout to the
|
|
354
|
+
# model — but the worker thread it ran on CANNOT be killed (a Python
|
|
355
|
+
# limitation), so it keeps running, parked on the prompt. The moment the user
|
|
356
|
+
# clicks "yes", that orphaned thread applies the mutation **out-of-band**,
|
|
357
|
+
# after the tool already claimed it timed out. An edit (or a delete) then
|
|
358
|
+
# lands that the user never knowingly approved in-context.
|
|
359
|
+
#
|
|
360
|
+
# To prevent that, ``check_permission`` marks a per-tool-call gate while it
|
|
361
|
+
# blocks on the user, and ``_thread_tool`` suspends its timeout for exactly
|
|
362
|
+
# as long as the gate is active. Decision time is the human's, not the
|
|
363
|
+
# tool's budget.
|
|
364
|
+
#
|
|
365
|
+
# Cross-thread mechanics: the gate object is created per tool call by the
|
|
366
|
+
# ``_thread_tool`` wrapper (on the event loop) and stored in a ContextVar.
|
|
367
|
+
# ``asyncio.to_thread`` copies the context into the worker thread, so
|
|
368
|
+
# ``check_permission`` running on that thread flips the SAME gate object the
|
|
369
|
+
# wrapper polls. Concurrent tool calls each get their own gate, so a prompt
|
|
370
|
+
# open for one call never accidentally exempts a sibling.
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class PermissionWaitGate:
|
|
374
|
+
"""Per-tool-call counter of in-flight human permission decisions.
|
|
375
|
+
|
|
376
|
+
A depth counter (not a bool) so re-entrant or repeated permission checks
|
|
377
|
+
within one tool call nest correctly. ``active`` is true whenever at least
|
|
378
|
+
one decision is outstanding.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
__slots__ = ("_depth", "_lock")
|
|
382
|
+
|
|
383
|
+
def __init__(self) -> None:
|
|
384
|
+
self._depth = 0
|
|
385
|
+
self._lock = threading.Lock()
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def active(self) -> bool:
|
|
389
|
+
with self._lock:
|
|
390
|
+
return self._depth > 0
|
|
391
|
+
|
|
392
|
+
def enter(self) -> None:
|
|
393
|
+
with self._lock:
|
|
394
|
+
self._depth += 1
|
|
395
|
+
|
|
396
|
+
def leave(self) -> None:
|
|
397
|
+
with self._lock:
|
|
398
|
+
if self._depth > 0:
|
|
399
|
+
self._depth -= 1
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_perm_wait_gate: contextvars.ContextVar[PermissionWaitGate | None] = (
|
|
403
|
+
contextvars.ContextVar("aru_perm_wait_gate", default=None)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def install_permission_wait_gate() -> tuple[PermissionWaitGate, contextvars.Token]:
|
|
408
|
+
"""Create a fresh gate, install it in the current context, return (gate, token).
|
|
409
|
+
|
|
410
|
+
Called by the ``_thread_tool`` wrapper before offloading to a worker
|
|
411
|
+
thread so the worker (which runs in a copy of this context) shares the
|
|
412
|
+
gate. Pair with ``reset_permission_wait_gate(token)`` in a finally.
|
|
413
|
+
"""
|
|
414
|
+
gate = PermissionWaitGate()
|
|
415
|
+
token = _perm_wait_gate.set(gate)
|
|
416
|
+
return gate, token
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def reset_permission_wait_gate(token: contextvars.Token) -> None:
|
|
420
|
+
"""Restore the previous gate binding (undo ``install_permission_wait_gate``)."""
|
|
421
|
+
_perm_wait_gate.reset(token)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def begin_permission_wait() -> None:
|
|
425
|
+
"""Mark that the current tool call is blocking on a human permission decision.
|
|
426
|
+
|
|
427
|
+
Paired with ``end_permission_wait()``. A no-op when no gate is installed
|
|
428
|
+
(async tools like ``bash`` that aren't wrapped by ``_thread_tool``, or
|
|
429
|
+
direct test calls) — those paths have no execution timeout to suspend.
|
|
430
|
+
"""
|
|
431
|
+
gate = _perm_wait_gate.get()
|
|
432
|
+
if gate is not None:
|
|
433
|
+
gate.enter()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def end_permission_wait() -> None:
|
|
437
|
+
"""End the permission-wait window opened by ``begin_permission_wait()``."""
|
|
438
|
+
gate = _perm_wait_gate.get()
|
|
439
|
+
if gate is not None:
|
|
440
|
+
gate.leave()
|
|
441
|
+
|
|
442
|
+
|
|
348
443
|
# ── Shared-state helpers (Stage 4) ───────────────────────────────────
|
|
349
444
|
#
|
|
350
445
|
# Individual ``dict[k] = v``, ``dict.get(k)``, and ``list.append`` are atomic
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Shared helpers used by multiple tool modules.
|
|
2
|
+
|
|
3
|
+
Split out of the former monolithic codebase.py. Imported by file_ops, search,
|
|
4
|
+
shell, web, and delegate. Intentionally has no dependencies on other tool
|
|
5
|
+
submodules so it sits at the bottom of the tool dependency graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
|
|
13
|
+
from aru.runtime import get_ctx
|
|
14
|
+
from aru.tools.gitignore import invalidate_walk_cache
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_MAX_OUTPUT_CHARS = 10_000
|
|
18
|
+
_TRUNCATE_KEEP = 3_000 # chars to keep from start and end
|
|
19
|
+
|
|
20
|
+
# How often (seconds) the timeout loop re-checks the permission gate while a
|
|
21
|
+
# human decision is in flight. Small enough that the post-decision write is
|
|
22
|
+
# noticed promptly; large enough not to busy-spin while the user is thinking.
|
|
23
|
+
_PERM_POLL_SLICE = 0.1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _notify_file_mutation(*, path: str | None = None, mutation_type: str = "unknown"):
|
|
27
|
+
"""Notify the session that files changed so caches are invalidated.
|
|
28
|
+
|
|
29
|
+
Also publishes ``file.changed`` via the plugin bus so plugins (auto-
|
|
30
|
+
linter, memory extractor, LSP didChange etc.) can react. ``path`` and
|
|
31
|
+
``mutation_type`` are optional and default to "unknown" for legacy
|
|
32
|
+
callers that haven't been updated yet.
|
|
33
|
+
"""
|
|
34
|
+
ctx = get_ctx()
|
|
35
|
+
ctx.read_cache.clear()
|
|
36
|
+
invalidate_walk_cache()
|
|
37
|
+
if ctx.on_file_mutation:
|
|
38
|
+
ctx.on_file_mutation()
|
|
39
|
+
from aru.runtime import _schedule_publish
|
|
40
|
+
_schedule_publish("file.changed", {
|
|
41
|
+
"path": path, "mutation_type": mutation_type,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _checkpoint_file(file_path: str):
|
|
46
|
+
"""Capture pre-edit state of a file for undo support.
|
|
47
|
+
|
|
48
|
+
Must be called BEFORE writing/editing the file.
|
|
49
|
+
"""
|
|
50
|
+
ctx = get_ctx()
|
|
51
|
+
if ctx.checkpoint_manager:
|
|
52
|
+
ctx.checkpoint_manager.track_edit(file_path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_small_model_ref() -> str:
|
|
56
|
+
"""Get the small model reference for sub-agents."""
|
|
57
|
+
return get_ctx().small_model_ref
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _truncate_output(text: str, source_file: str = "", source_tool: str = "") -> str:
|
|
61
|
+
"""Truncate long tool output to save tokens. Keeps start + end with a marker in the middle."""
|
|
62
|
+
from aru.context import truncate_output
|
|
63
|
+
return truncate_output(text, source_file=source_file, source_tool=source_tool)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _thread_tool(sync_fn, *, timeout: float | None = None):
|
|
67
|
+
"""Wrap *sync_fn* as an async tool that runs on a worker thread.
|
|
68
|
+
|
|
69
|
+
``functools.wraps`` copies ``__name__``/``__doc__`` so Agno introspects
|
|
70
|
+
the wrapper as if it were the original sync function — tool name and
|
|
71
|
+
signature match what the LLM already knows.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
sync_fn: The synchronous implementation to offload to a worker.
|
|
75
|
+
timeout: Optional wall-clock cap (seconds). ``None`` (default) keeps
|
|
76
|
+
the historical behaviour of unbounded wait — callers opt into a
|
|
77
|
+
cap explicitly. Required because ``asyncio.to_thread`` cannot
|
|
78
|
+
actually abort the underlying worker thread (Python limitation):
|
|
79
|
+
on timeout, the REPL regains control but the thread may keep
|
|
80
|
+
running until its sync work finishes. Applying a blanket
|
|
81
|
+
default would break custom plugin tools that legitimately take
|
|
82
|
+
longer than the cap.
|
|
83
|
+
|
|
84
|
+
Permission-wait suspension (safety-critical): if the wrapped tool calls
|
|
85
|
+
``check_permission`` and blocks on a human decision, the timeout is
|
|
86
|
+
suspended for the duration of that prompt. Without this, the timeout
|
|
87
|
+
could fire mid-prompt, report a timeout to the model, and leave the
|
|
88
|
+
worker thread alive to apply the mutation out-of-band once the user
|
|
89
|
+
finally answered. See ``aru.runtime.PermissionWaitGate``.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
@functools.wraps(sync_fn)
|
|
93
|
+
async def wrapper(*args, **kwargs):
|
|
94
|
+
if timeout is None:
|
|
95
|
+
return await asyncio.to_thread(sync_fn, *args, **kwargs)
|
|
96
|
+
|
|
97
|
+
from aru.runtime import (
|
|
98
|
+
install_permission_wait_gate,
|
|
99
|
+
reset_permission_wait_gate,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Install the per-call gate BEFORE offloading so the worker thread
|
|
103
|
+
# (which runs in a copy of this context) shares the same gate object
|
|
104
|
+
# and ``check_permission`` can flip it while it blocks on the user.
|
|
105
|
+
gate, token = install_permission_wait_gate()
|
|
106
|
+
task: asyncio.Future | None = None
|
|
107
|
+
try:
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
task = asyncio.ensure_future(asyncio.to_thread(sync_fn, *args, **kwargs))
|
|
110
|
+
deadline = loop.time() + timeout
|
|
111
|
+
while True:
|
|
112
|
+
if task.done():
|
|
113
|
+
return task.result()
|
|
114
|
+
now = loop.time()
|
|
115
|
+
if gate.active:
|
|
116
|
+
# A human is being asked to approve this tool call. Their
|
|
117
|
+
# decision time is not the tool's execution budget — and
|
|
118
|
+
# abandoning the worker thread now would let it apply the
|
|
119
|
+
# mutation the instant the user answers, after we already
|
|
120
|
+
# reported a timeout. Hold the deadline a full window
|
|
121
|
+
# ahead so that, once the prompt closes, the actual work
|
|
122
|
+
# still gets the complete budget (this closes the
|
|
123
|
+
# answer→write race: when ``gate.active`` flips false the
|
|
124
|
+
# deadline is at most one poll-slice old).
|
|
125
|
+
deadline = now + timeout
|
|
126
|
+
await asyncio.wait({task}, timeout=_PERM_POLL_SLICE)
|
|
127
|
+
continue
|
|
128
|
+
remaining = deadline - now
|
|
129
|
+
if remaining <= 0:
|
|
130
|
+
# Genuine timeout — no human in the loop. Request
|
|
131
|
+
# cancellation (best-effort; the OS thread may run on,
|
|
132
|
+
# same as the historical behaviour) and surface a string.
|
|
133
|
+
task.cancel()
|
|
134
|
+
return (
|
|
135
|
+
f"[Tool timeout: {sync_fn.__name__} exceeded {timeout:g}s. "
|
|
136
|
+
f"The worker thread may still be running in the background; "
|
|
137
|
+
f"narrow the query or raise the timeout explicitly.]"
|
|
138
|
+
)
|
|
139
|
+
await asyncio.wait({task}, timeout=remaining)
|
|
140
|
+
finally:
|
|
141
|
+
if task is not None and not task.done():
|
|
142
|
+
task.cancel()
|
|
143
|
+
reset_permission_wait_gate(token)
|
|
144
|
+
|
|
145
|
+
return wrapper
|
|
@@ -27,6 +27,10 @@ import shutil
|
|
|
27
27
|
from dataclasses import dataclass, field
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
+
from rich.console import Group
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
from aru.permissions import check_permission, consume_rejection_feedback
|
|
30
34
|
from aru.tools._shared import _checkpoint_file, _notify_file_mutation
|
|
31
35
|
|
|
32
36
|
|
|
@@ -499,7 +503,86 @@ except OSError:
|
|
|
499
503
|
_PROMPT_TEXT = "Apply a multi-file patch atomically (see apply_patch_prompt.txt)."
|
|
500
504
|
|
|
501
505
|
|
|
506
|
+
_PREVIEW_MAX_ADD_LINES = 40 # cap new-file body shown in the prompt
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _patch_permission_preview(patch: Patch) -> tuple[list[str], Group]:
|
|
510
|
+
"""Build the (subjects, renderable) pair for the permission prompt.
|
|
511
|
+
|
|
512
|
+
Subjects are the affected paths (one per op, plus the move target) so the
|
|
513
|
+
user's per-path rules apply. The renderable shows a real diff — the actual
|
|
514
|
+
+/- lines for each Update hunk and Add body — so the user approves what
|
|
515
|
+
they can see, not a blind "update". Deletes and moves are highlighted;
|
|
516
|
+
those are the irreversible ones. New-file bodies are capped so a large
|
|
517
|
+
patch can't flood the terminal.
|
|
518
|
+
"""
|
|
519
|
+
subjects: list[str] = []
|
|
520
|
+
blocks: list[Text] = [
|
|
521
|
+
Text(f"apply_patch — {len(patch.operations)} operation(s):", style="bold"),
|
|
522
|
+
Text(),
|
|
523
|
+
]
|
|
524
|
+
for op in patch.operations:
|
|
525
|
+
if isinstance(op, AddFile):
|
|
526
|
+
subjects.append(op.path)
|
|
527
|
+
blocks.append(Text(f"+ add {op.path}", style="bold green"))
|
|
528
|
+
body = op.content.splitlines()
|
|
529
|
+
for line in body[:_PREVIEW_MAX_ADD_LINES]:
|
|
530
|
+
blocks.append(Text(f" +{line}", style="green"))
|
|
531
|
+
if len(body) > _PREVIEW_MAX_ADD_LINES:
|
|
532
|
+
blocks.append(Text(f" … (+{len(body) - _PREVIEW_MAX_ADD_LINES} more lines)", style="dim"))
|
|
533
|
+
elif isinstance(op, DeleteFile):
|
|
534
|
+
subjects.append(op.path)
|
|
535
|
+
blocks.append(Text(f"- delete {op.path}", style="bold red"))
|
|
536
|
+
elif isinstance(op, UpdateFile):
|
|
537
|
+
subjects.append(op.path)
|
|
538
|
+
header = f"~ update {op.path}"
|
|
539
|
+
if op.move_to:
|
|
540
|
+
subjects.append(op.move_to)
|
|
541
|
+
header += f" → {op.move_to}"
|
|
542
|
+
blocks.append(Text(header, style="bold yellow"))
|
|
543
|
+
for hunk in op.hunks:
|
|
544
|
+
if hunk.anchor:
|
|
545
|
+
blocks.append(Text(f" @@ {hunk.anchor}", style="cyan"))
|
|
546
|
+
for tag, text in hunk.lines:
|
|
547
|
+
if tag == "+":
|
|
548
|
+
blocks.append(Text(f" +{text}", style="green"))
|
|
549
|
+
elif tag == "-":
|
|
550
|
+
blocks.append(Text(f" -{text}", style="red"))
|
|
551
|
+
else:
|
|
552
|
+
blocks.append(Text(f" {text}", style="dim"))
|
|
553
|
+
blocks.append(Text())
|
|
554
|
+
return subjects, Group(*blocks)
|
|
555
|
+
|
|
556
|
+
|
|
502
557
|
def apply_patch(patch: str) -> str:
|
|
558
|
+
# Parse + validate FIRST (neither touches disk for writes — validate only
|
|
559
|
+
# reads). This lets us reject malformed / non-applicable patches without
|
|
560
|
+
# bothering the user, and show exactly which files will change. ONLY then
|
|
561
|
+
# do we gate on permission, and only on approval do we apply. This is the
|
|
562
|
+
# security boundary: apply_patch can delete and move files, so it must
|
|
563
|
+
# never mutate the tree without an explicit allow.
|
|
564
|
+
try:
|
|
565
|
+
parsed = parse_patch(patch)
|
|
566
|
+
validate(parsed)
|
|
567
|
+
except PatchParseError as exc:
|
|
568
|
+
return f"Parse error: {exc}"
|
|
569
|
+
except PatchValidationError as exc:
|
|
570
|
+
return f"Validation error (no files modified): {exc}"
|
|
571
|
+
|
|
572
|
+
subjects, preview = _patch_permission_preview(parsed)
|
|
573
|
+
if not check_permission("edit", subjects, preview):
|
|
574
|
+
feedback = consume_rejection_feedback()
|
|
575
|
+
action = f"apply_patch ({len(parsed.operations)} operation(s))"
|
|
576
|
+
if feedback:
|
|
577
|
+
return (
|
|
578
|
+
f"PERMISSION DENIED by user: {action}. The user said: {feedback}\n"
|
|
579
|
+
f"Follow the user's instructions instead of retrying."
|
|
580
|
+
)
|
|
581
|
+
return (
|
|
582
|
+
f"PERMISSION DENIED by user: {action}. Do NOT retry this operation. "
|
|
583
|
+
f"Stop and ask the user for new instructions."
|
|
584
|
+
)
|
|
585
|
+
|
|
503
586
|
try:
|
|
504
587
|
return apply_patch_text(patch)
|
|
505
588
|
except PatchParseError as exc:
|
|
@@ -8,12 +8,17 @@ strings rather than raises — the model can fall back to grep-based tools.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
11
12
|
import logging
|
|
12
13
|
import os
|
|
13
14
|
|
|
15
|
+
from rich.console import Group
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
14
18
|
from aru.lsp.client import LspRequestError
|
|
15
19
|
from aru.lsp.manager import get_lsp_manager
|
|
16
20
|
from aru.lsp.protocol import Location, Position, path_to_uri, uri_to_path
|
|
21
|
+
from aru.permissions import check_permission, consume_rejection_feedback
|
|
17
22
|
|
|
18
23
|
logger = logging.getLogger("aru.lsp")
|
|
19
24
|
|
|
@@ -194,6 +199,30 @@ async def lsp_rename(file_path: str, line: int, column: int, new_name: str) -> s
|
|
|
194
199
|
if not per_file_edits:
|
|
195
200
|
return "No files to edit."
|
|
196
201
|
|
|
202
|
+
# Gate on permission before touching any file — a rename rewrites every
|
|
203
|
+
# referencing file across the workspace, so the user must approve the
|
|
204
|
+
# full set. ``check_permission`` is sync and may open a TUI modal that
|
|
205
|
+
# blocks on ``threading.Event``; calling it directly from this async tool
|
|
206
|
+
# (running on the App loop) would deadlock the loop so the modal never
|
|
207
|
+
# resolves. Hop to a worker thread — same pattern as ``bash``.
|
|
208
|
+
paths = sorted({uri_to_path(uri) for uri in per_file_edits})
|
|
209
|
+
preview = Group(
|
|
210
|
+
Text(f"Rename symbol → {new_name!r} across {len(paths)} file(s):", style="bold"),
|
|
211
|
+
*[Text(f" ~ {p}") for p in paths],
|
|
212
|
+
)
|
|
213
|
+
if not await asyncio.to_thread(check_permission, "edit", paths, preview):
|
|
214
|
+
feedback = consume_rejection_feedback()
|
|
215
|
+
if feedback:
|
|
216
|
+
return (
|
|
217
|
+
f"PERMISSION DENIED by user: lsp_rename → {new_name!r}. "
|
|
218
|
+
f"The user said: {feedback}\n"
|
|
219
|
+
f"Follow the user's instructions instead of retrying."
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
f"PERMISSION DENIED by user: lsp_rename → {new_name!r}. Do NOT retry "
|
|
223
|
+
f"this operation. Stop and ask the user for new instructions."
|
|
224
|
+
)
|
|
225
|
+
|
|
197
226
|
applied = _apply_workspace_edit(per_file_edits)
|
|
198
227
|
if isinstance(applied, str): # error path
|
|
199
228
|
return applied
|
|
@@ -42,6 +42,15 @@ from aru.tools.worktree import worktree_info
|
|
|
42
42
|
|
|
43
43
|
_rank_files_tool = _thread_tool(rank_files, timeout=45)
|
|
44
44
|
|
|
45
|
+
# apply_patch now prompts for permission (it can delete/move files). The
|
|
46
|
+
# prompt blocks on a threading.Event, which would deadlock the event loop if
|
|
47
|
+
# the raw sync function ran there — so wrap it like the other mutating file
|
|
48
|
+
# tools: run on a worker thread (loop stays free to drive the modal) and pick
|
|
49
|
+
# up the permission-wait timeout suspension for free. functools.wraps keeps
|
|
50
|
+
# __name__ == "apply_patch" so the registry key and LLM-facing schema are
|
|
51
|
+
# unchanged.
|
|
52
|
+
_apply_patch_tool = _thread_tool(apply_patch, timeout=60)
|
|
53
|
+
|
|
45
54
|
|
|
46
55
|
# Tool sets composed from a single core set — avoid duplication and drift.
|
|
47
56
|
_READ_ONLY_TOOLS = [
|
|
@@ -63,7 +72,7 @@ _WRITE_TOOLS = [
|
|
|
63
72
|
_write_files_tool,
|
|
64
73
|
_edit_file_tool,
|
|
65
74
|
_edit_files_tool,
|
|
66
|
-
|
|
75
|
+
_apply_patch_tool,
|
|
67
76
|
lsp_rename,
|
|
68
77
|
]
|
|
69
78
|
|
|
@@ -138,19 +138,27 @@ class TuiUI:
|
|
|
138
138
|
result["error"] = f"mount failed: {exc}"
|
|
139
139
|
done.set()
|
|
140
140
|
|
|
141
|
+
# Hide the "thinking…" spinner while the prompt owns the screen — we
|
|
142
|
+
# are parked waiting on the user, not computing, so an animated
|
|
143
|
+
# spinner there is misleading. Restored in the finally regardless of
|
|
144
|
+
# how the wait ends.
|
|
145
|
+
self._suppress_thinking()
|
|
141
146
|
try:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
try:
|
|
148
|
+
self.app.call_from_thread(_mount)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"TuiUI inline-choice dispatch failed: "
|
|
152
|
+
f"{type(exc).__name__}: {exc}"
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
if not done.wait(timeout=timeout_s):
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"TuiUI inline-choice timed out after {timeout_s:.0f}s"
|
|
158
|
+
)
|
|
159
|
+
return result.get("value")
|
|
160
|
+
finally:
|
|
161
|
+
self._release_thinking()
|
|
154
162
|
|
|
155
163
|
# ── confirm ───────────────────────────────────────────────────────
|
|
156
164
|
|
|
@@ -208,6 +216,34 @@ class TuiUI:
|
|
|
208
216
|
|
|
209
217
|
# ── internal ──────────────────────────────────────────────────────
|
|
210
218
|
|
|
219
|
+
def _suppress_thinking(self) -> None:
|
|
220
|
+
"""Hide the ThinkingIndicator while a blocking prompt is open.
|
|
221
|
+
|
|
222
|
+
Safe to call from a tool worker thread — the widget mutation is
|
|
223
|
+
marshalled onto the App loop. Best-effort: any failure (App shutting
|
|
224
|
+
down, indicator not mounted) is swallowed so a UI hiccup never blocks
|
|
225
|
+
a permission decision. Paired with ``_release_thinking``.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
from aru.tui.widgets.thinking import ThinkingIndicator
|
|
229
|
+
|
|
230
|
+
self.app.call_from_thread(
|
|
231
|
+
lambda: self.app.query_one(ThinkingIndicator).suppress()
|
|
232
|
+
)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def _release_thinking(self) -> None:
|
|
237
|
+
"""Undo one ``_suppress_thinking`` once the prompt closes."""
|
|
238
|
+
try:
|
|
239
|
+
from aru.tui.widgets.thinking import ThinkingIndicator
|
|
240
|
+
|
|
241
|
+
self.app.call_from_thread(
|
|
242
|
+
lambda: self.app.query_one(ThinkingIndicator).release()
|
|
243
|
+
)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
211
247
|
def _run_modal(self, modal: Any, timeout_s: float = 300.0) -> Any:
|
|
212
248
|
"""Push a ModalScreen and block until it is dismissed.
|
|
213
249
|
|
|
@@ -225,15 +261,21 @@ class TuiUI:
|
|
|
225
261
|
result["value"] = value
|
|
226
262
|
done.set()
|
|
227
263
|
|
|
264
|
+
# Park the spinner while the modal owns the screen (see
|
|
265
|
+
# _run_inline_choice). Restored in the finally.
|
|
266
|
+
self._suppress_thinking()
|
|
228
267
|
try:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
try:
|
|
269
|
+
self.app.call_from_thread(self.app.push_screen, modal, _on_dismiss)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
f"TuiUI modal dispatch failed: {type(e).__name__}: {e}"
|
|
273
|
+
) from e
|
|
274
|
+
|
|
275
|
+
if not done.wait(timeout=timeout_s):
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
f"TuiUI modal timed out after {timeout_s:.0f}s"
|
|
278
|
+
)
|
|
279
|
+
return result.get("value")
|
|
280
|
+
finally:
|
|
281
|
+
self._release_thinking()
|
|
@@ -54,6 +54,14 @@ class ThinkingIndicator(Widget):
|
|
|
54
54
|
TICK_SECONDS: float = 0.1
|
|
55
55
|
|
|
56
56
|
busy: reactive[bool] = reactive(False, layout=True)
|
|
57
|
+
# Incremented while a blocking prompt (permission / confirm / text input)
|
|
58
|
+
# owns the screen. The spinner means "a turn is in flight", but while we
|
|
59
|
+
# are parked waiting on the user there is nothing to animate — showing it
|
|
60
|
+
# there reads as "still processing" and is misleading. The indicator is
|
|
61
|
+
# hidden whenever suppress_depth > 0, regardless of ``busy``. A depth
|
|
62
|
+
# counter (not a bool) keeps back-to-back prompts (e.g. "No" → feedback
|
|
63
|
+
# box) suppressed throughout, without a flash between them.
|
|
64
|
+
suppress_depth: reactive[int] = reactive(0, layout=True)
|
|
57
65
|
|
|
58
66
|
def __init__(self) -> None:
|
|
59
67
|
super().__init__()
|
|
@@ -66,16 +74,35 @@ class ThinkingIndicator(Widget):
|
|
|
66
74
|
def on_mount(self) -> None:
|
|
67
75
|
self.set_interval(self.TICK_SECONDS, self._tick)
|
|
68
76
|
|
|
77
|
+
def _visible(self) -> bool:
|
|
78
|
+
return self.busy and self.suppress_depth == 0
|
|
79
|
+
|
|
80
|
+
def _apply_visibility(self) -> None:
|
|
81
|
+
if self._visible():
|
|
82
|
+
self.add_class("-busy")
|
|
83
|
+
else:
|
|
84
|
+
self.remove_class("-busy")
|
|
85
|
+
|
|
69
86
|
def watch_busy(self, _old: bool, new: bool) -> None:
|
|
70
87
|
if new:
|
|
71
|
-
self.add_class("-busy")
|
|
72
88
|
self._index = 0
|
|
73
89
|
self._ticks_since_rotate = 0
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
self._apply_visibility()
|
|
91
|
+
|
|
92
|
+
def watch_suppress_depth(self, _old: int, _new: int) -> None:
|
|
93
|
+
self._apply_visibility()
|
|
94
|
+
|
|
95
|
+
def suppress(self) -> None:
|
|
96
|
+
"""Hide the spinner while a blocking prompt owns the screen."""
|
|
97
|
+
self.suppress_depth += 1
|
|
98
|
+
|
|
99
|
+
def release(self) -> None:
|
|
100
|
+
"""Undo one ``suppress()``; spinner returns only if the turn is still busy."""
|
|
101
|
+
if self.suppress_depth > 0:
|
|
102
|
+
self.suppress_depth -= 1
|
|
76
103
|
|
|
77
104
|
def _tick(self) -> None:
|
|
78
|
-
if not self.
|
|
105
|
+
if not self._visible():
|
|
79
106
|
return
|
|
80
107
|
self._ticks_since_rotate += 1
|
|
81
108
|
if self._ticks_since_rotate * self.TICK_SECONDS >= self.ROTATE_SECONDS:
|
|
@@ -151,6 +151,7 @@ tests/test_mcp_health.py
|
|
|
151
151
|
tests/test_memory.py
|
|
152
152
|
tests/test_memory_tool.py
|
|
153
153
|
tests/test_microcompact.py
|
|
154
|
+
tests/test_permission_timeout_suspension.py
|
|
154
155
|
tests/test_permissions.py
|
|
155
156
|
tests/test_plan_mode_refactor.py
|
|
156
157
|
tests/test_plugin_cache.py
|